Skip to content

Commit 7bf3361

Browse files
Quickbeardclaude
andcommitted
fix(desktop): enable the WSL component Windows 10 requires
On the affected Windows 10 machine the import failure survived the restart, and `wsl --status` named the real cause: Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED. Windows 10 requires the Microsoft-Windows-Subsystem-Linux optional component even for the standalone MSI, and the elevated install script only ever enabled VirtualMachinePlatform — while `wsl --version`, the availability probe, kept passing straight from the MSI. The script now enables both features (dism is a no-op where the component already exists, so no build gate), with 3010 from either re-raised as before. The sandbox provider's availability probe becomes `wsl --status`, which fails — and says why — exactly when running a distro would, so a half-installed machine is offered the now complete installer instead of a doomed import; its message is surfaced over the generic install prompt, which remains for a missing wsl.exe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 99e69b4 commit 7bf3361

7 files changed

Lines changed: 77 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,15 @@ file is the human-readable summary of what changed and why.
100100
Windows** button right there, so you are not sent to the Start menu
101101
mid-flow: it asks you to confirm first — restarting closes CoDev and every
102102
other open application — and an application holding unsaved work can still
103-
hold the restart, because Windows is asked to restart, not forced to. And
103+
hold the restart, because Windows is asked to restart, not forced to.
104+
Windows 10 needed one more thing the installer never enabled: the Windows
105+
Subsystem for Linux component itself, which Windows 10 requires even for
106+
the standalone WSL the app installs — so a Windows 10 machine failed the
107+
same way even after restarting. The installer enables it now, and the
108+
"is WSL ready" check behind the dialogs asks WSL to actually stand up
109+
(`wsl --status`) rather than merely answer a version query, so a machine
110+
in that half-installed state is offered the (now complete) installer
111+
instead of a doomed setup. And
104112
when a WSL command fails anyway, the error now
105113
carries WSL's own explanation — WSL prints its errors on the output stream
106114
the app wasn't reading — with a hint to restart Windows, or to enable

‎DIVERGENCES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ from the squash message first, or use a plain merge.
204204
| `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern). | Keep the export; take upstream's other manifest changes. |
205205
| `packages/app/src/i18n/{en,es}.ts` | Added the `gateway.configure.*` key family (configure-gate dialog) and reworded `sidebar.gettingStarted.line1` — the "includes free models" pitch no longer holds with the hosted providers hidden. | Strings for the fork's configure gate; the getting-started card must not advertise models the fork does not offer. | Keep our keys and copy; take upstream's surrounding i18n changes. |
206206
| `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`; `autoUpdater.quitAndInstall()` → `quitAndInstall(true, true)`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. The `quitAndInstall` arguments are load-bearing against the assisted installer (see the `electron-builder.config.ts` row): the defaults are `isSilent = false, isForceRunAfter = false`, and `NsisUpdater.doInstall` appends `/S` only when `isSilent` — so with an assisted installer the default would run the full wizard on **every** update, re-asking install mode and directory for a location the user already chose. `isForceRunAfter` is honoured only when `isSilent` is set (`BaseUpdater.quitAndInstall` passes `autoRunAppAfterInstall` instead when it is not), so both arguments are needed to install quietly *and* come back up. | Keep `false`; take upstream's other updater settings. Keep `quitAndInstall(true, true)` for as long as `nsis.oneClick` is `false`; the two only make sense together. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). |
207-
| Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; the `install-wsl` job additionally treats exit 3010 (`WSL_INSTALL_REBOOT_REQUIRED`, re-raised by the fork's elevated script) as success with `pendingRestart: true` rather than as a failure — `wsl --version` answers from the freshly installed MSI before the just-enabled VirtualMachinePlatform is active, so no post-install probe can detect the pending reboot; otherwise both return the same `WslCommandResult` shape and the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads from the codev storage host — the runtime as a per-architecture MSI named by its own manifest (`/codev/installers/wsl/manifest.json`, assets under `/codev/installers/wsl/<version>/`) and staged through `stageAsset` for resume + sha256 verification, the distro from the pinned `wsl.zip` — enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the download cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps, the 3010 handling in `install-wsl`, and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. |
207+
| Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; the `install-wsl` job additionally treats exit 3010 (`WSL_INSTALL_REBOOT_REQUIRED`, re-raised by the fork's elevated script) as success with `pendingRestart: true` rather than as a failure — `wsl --version` answers from the freshly installed MSI before the just-enabled VirtualMachinePlatform is active, so no post-install probe can detect the pending reboot; otherwise both return the same `WslCommandResult` shape and the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads from the codev storage host — the runtime as a per-architecture MSI named by its own manifest (`/codev/installers/wsl/manifest.json`, assets under `/codev/installers/wsl/<version>/`) and staged through `stageAsset` for resume + sha256 verification, the distro from the pinned `wsl.zip` — enables VirtualMachinePlatform and Microsoft-Windows-Subsystem-Linux via offline dism + installs the MSI behind one UAC prompt (Windows 10 requires the WSL optional component even for the standalone MSI — without it every `wsl` distro command fails with Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED; on machines that already have it dism is a no-op), and falls back to upstream's `wsl --install` / `--web-download` paths only when the download cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps, the 3010 handling in `install-wsl`, and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. |
208208
| Async settings stores: `packages/desktop/src/main/{store,index}.ts`, `packages/desktop/package.json` | `store.ts` no longer uses electron-store: `getStore` now returns the fork-only `store-file.ts` store — in-memory state, flushed write-behind through `store-writer.ts`'s serialized latest-wins async atomic writer (temp → fsync → rename via `fs.promises`, so the fsync runs on the libuv threadpool). Adds `flushStoreWrites(timeoutMs)`; `removeStoreFile`/`removeStoreFileIfEmpty` discard/flush the writer around the delete so a late flush cannot resurrect the file. `index.ts`: `stopSidecars` awaits `flushStoreWrites(1500)`. `package.json`: the `electron-store` dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. | conf v15's `store.set` is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. | Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend `store-file.ts` to match — its API is the superset the desktop actually uses (`get/set/has/delete/clear/store/path`). Do not reintroduce a synchronous write path on the main thread. |
209209
| Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. |
210210
| `packages/app/src/components/server/server-row-menu.tsx` | The trailing **Delete** item carries `style={{ "--menu-v2-fg": "var(--v2-state-fg-danger)" }}`. Nothing else changes — same `disabled={builtin}`, same `handleRemove(key)`, same position after the separator. | Delete rendered in the same colour as Edit and Set as default, so the one irreversible action in the menu carried no more weight than the rest. The session actions menu already colours its Delete this way; this brings the server menu in line. It has to be the CSS variable rather than a text utility: `menu-v2.css` colours the item **and** its `menu-v2-item-content` label span from `--menu-v2-fg`, so a class on the item never reaches the label. | Keep the style prop. On conflict take upstream's item and re-apply it. Do not swap it for a Tailwind text colour — that silently only recolours half the item. The disabled state dims by `opacity` alone, so the red survives it. |

‎packages/codev-sandbox/src/wsl/provider.test.ts‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const args = process.argv.slice(2)
2525
const out = (text) => process.stdout.write(Buffer.from(text, "utf16le"))
2626
2727
if (args[0] === "--version") { out("WSL version: 2.3.24.0\\r\\n"); process.exit(0) }
28+
if (args[0] === "--status") { out("Default Version: 2\\r\\n"); process.exit(0) }
2829
if (args[0] === "--list") { out(read().distros.join("\\r\\n") + "\\r\\n"); process.exit(0) }
2930
if (args[0] === "--import") {
3031
const value = read(); value.distros.push(args[1]); value.imports = (value.imports ?? 0) + 1; write(value)
@@ -189,11 +190,34 @@ test("availability survives an unreachable installer manifest", async () => {
189190
await rm(dir, { recursive: true, force: true })
190191
})
191192

192-
test("availability reports unsupported when wsl --version fails", async () => {
193+
// The machine that shaped the --status probe: the MSI is installed, so
194+
// --version passes, but Windows 10 without the WSL optional component cannot
195+
// run a distro — and --status names the missing piece.
196+
test("availability surfaces why an installed WSL cannot run", async () => {
193197
const dir = await mkdtemp(join(tmpdir(), "wsl-test-"))
194-
const missing = join(dir, "missing-wsl.mjs")
195-
await writeFile(missing, `process.exit(1)`)
196-
const provider = createWslProvider(await preparedOptions(dir, { wslCommand: missing }))
198+
const fake = join(dir, "fake-wsl-status.mjs")
199+
await writeFile(
200+
fake,
201+
`const out = (text) => process.stdout.write(Buffer.from(text, "utf16le"))
202+
if (process.argv[2] === "--version") { out("WSL version: 2.3.24.0\\r\\n"); process.exit(0) }
203+
out("This application requires the Windows Subsystem for Linux Optional Component.\\r\\nError code: Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED\\r\\n")
204+
process.exit(1)
205+
`,
206+
)
207+
const provider = createWslProvider(await preparedOptions(dir, { wslCommand: fake }))
208+
const availability = await provider.availability()
209+
expect(availability.supported).toBe(false)
210+
if (!availability.supported) expect(availability.message).toContain("WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED")
211+
await rm(dir, { recursive: true, force: true })
212+
})
213+
214+
// wsl.exe missing entirely (spawn fails): the one case with no wsl.exe output
215+
// to show, so the generic install prompt is the message.
216+
test("availability reports unsupported when wsl.exe is not there at all", async () => {
217+
const dir = await mkdtemp(join(tmpdir(), "wsl-test-"))
218+
const provider = createWslProvider(
219+
await preparedOptions(dir, { wslCommand: join(dir, "missing-wsl.exe"), spawnImpl: spawn }),
220+
)
197221
const availability = await provider.availability()
198222
expect(availability.supported).toBe(false)
199223
expect(availability.supported === false && availability.message).toContain("WSL 2 is not installed")

‎packages/codev-sandbox/src/wsl/provider.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,23 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider
177177

178178
async availability(): Promise<SandboxAvailability> {
179179
if (platform !== "win32") return { supported: false, message: "WSL sandboxes require Windows" }
180-
const version = await wsl(["--version"]).catch(() => null)
181-
if (!version || version.code !== 0)
180+
// --status, not --version: --version answers from the installed MSI
181+
// alone and passed on machines where WSL could not run — a restart
182+
// still pending, or Windows 10 without the WSL optional component
183+
// (observed live as Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED while
184+
// --version reported 2.x). --status fails, and says why, exactly when
185+
// running a distro would; its reason is worth showing over a generic
186+
// line, and the dialogs offer the app's own installer either way.
187+
const status = await wsl(["--status"]).catch(() => null)
188+
if (!status || status.code !== 0) {
189+
const detail = status ? wslFailureDetail(status) : null
182190
return {
183191
supported: false,
184-
message: "WSL 2 is not installed. Install it to use the sandbox, then try again.",
192+
message: detail
193+
? `WSL 2 is not ready: ${detail}`
194+
: "WSL 2 is not installed. Install it to use the sandbox, then try again.",
185195
}
196+
}
186197
const build = windowsBuildNumber(osRelease())
187198
// Only asked when a warning is actually in play. Every Windows 11 build
188199
// is above every floor Microsoft has published for WSL, so on the common

‎packages/desktop/src/main/sandbox/wsl-install.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,7 @@ export function createWslRuntimeInstaller(deps: {
143143
return
144144
}
145145
// Backstop for the fallback path (`wsl --install`), which reports no
146-
// reboot signal of its own: ask WSL whether it answers now, the same
147-
// test the Code side makes after its own install.
146+
// reboot signal of its own: ask WSL whether it answers now.
148147
const availability = await deps.availability()
149148
logger.log("WSL install finished", { supported: availability.supported })
150149
setState(availability.supported ? { status: "installed" } : { status: "restart-required" })

‎packages/desktop/src/main/wsl/install-bundle.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,13 +206,17 @@ test("fails loudly when the zip is missing the distro image", async () => {
206206
test("elevated script re-raises reboot-required exits and quotes the MSI path", () => {
207207
const script = wslRuntimeInstallScript(`C:\\Users\\O'Brien\\wsl.msi`)
208208
expect(script).toContain("/featurename:VirtualMachinePlatform")
209+
// Windows 10 requires the WSL optional component even for the standalone
210+
// MSI (Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED without it).
211+
expect(script).toContain("/featurename:Microsoft-Windows-Subsystem-Linux")
209212
expect(script).toContain("'C:\\Users\\O''Brien\\wsl.msi'")
210-
// 3010 = ERROR_SUCCESS_REBOOT_REQUIRED, expected from both dism and msiexec.
211-
expect(script).toContain("($dism -ne 0) -and ($dism -ne 3010)")
213+
// 3010 = ERROR_SUCCESS_REBOOT_REQUIRED, expected from dism and msiexec.
214+
expect(script).toContain("($vmp -ne 0) -and ($vmp -ne 3010)")
215+
expect(script).toContain("($wsl -ne 0) -and ($wsl -ne 3010)")
212216
expect(script).toContain("($msi.ExitCode -ne 0) -and ($msi.ExitCode -ne 3010)")
213217
// Re-raised, not mapped to 0: the exit code is the only signal that crosses
214218
// the RunAs boundary, and no later wsl.exe probe can recover it.
215-
expect(script).toContain("if (($dism -eq 3010) -or ($msi.ExitCode -eq 3010)) { exit 3010 }")
219+
expect(script).toContain("if (($vmp -eq 3010) -or ($wsl -eq 3010) -or ($msi.ExitCode -eq 3010)) { exit 3010 }")
216220
})
217221

218222
test("only Ubuntu-24.04 is served from the bundle", () => {

0 commit comments

Comments
 (0)