From d33c88dfc5481bbcbde6f6666aa5fccc23a3fa4d Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Wed, 19 Aug 2026 15:55:07 +0700 Subject: [PATCH 1/3] fix(desktop): detect the WSL restart Windows still needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing WSL 2 on a machine that never had VirtualMachinePlatform ends with dism/msiexec exit 3010 (ERROR_SUCCESS_REBOOT_REQUIRED), but the elevated script mapped that to 0 and the follow-up probe was `wsl --version`, which answers from the freshly installed MSI before the feature is active. The app then declared WSL installed and went straight to creating the sandbox, whose `wsl --import` needs the WSL2 utility VM and failed — as "exited 4294967295" with no explanation, because wsl.exe prints its diagnostics to stdout while the provider only surfaced stderr. The elevated script now re-raises 3010 (the exit code is the only signal that crosses the RunAs boundary); the sandbox installer turns it into the existing restart-required state and the Code side's install-wsl job into pendingRestart. WSL command failures now include the stdout diagnostic, and an import failure in the VM-unavailable error family adds a hint to restart Windows or enable virtualization in firmware. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 14 ++++++++ DIVERGENCES.md | 2 +- packages/codev-sandbox/src/wsl/cli.ts | 10 ++++++ .../codev-sandbox/src/wsl/provider.test.ts | 35 ++++++++++++++++++- packages/codev-sandbox/src/wsl/provider.ts | 33 ++++++++++------- .../src/main/sandbox/wsl-install.test.ts | 22 ++++++++++-- .../desktop/src/main/sandbox/wsl-install.ts | 21 +++++++---- .../src/main/wsl/install-bundle.test.ts | 5 ++- .../desktop/src/main/wsl/install-bundle.ts | 13 +++++-- packages/desktop/src/main/wsl/servers.ts | 11 +++++- 10 files changed, 138 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e857462234..35afd2a98b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,20 @@ file is the human-readable summary of what changed and why. told so — naming its build and the one it needs — before it downloads anything, instead of after the install fails. +- **Installing WSL now says when Windows must restart first** — on a computer + that never had WSL 2, the install enables a Windows feature that only starts + working after a restart. The app declared the install finished — WSL answers + a version query before that restart — and carried straight on to creating + the sandbox, which failed with a bare `exited 4294967295` from + `wsl --import` and nothing to go on. The + installer now recognizes Windows' restart-required answer and the dialog + says to restart Windows and come back, in both the sandbox and the Code + side's WSL install. And when a WSL command fails anyway, the error now + carries WSL's own explanation — WSL prints its errors on the output stream + the app wasn't reading — with a hint to restart Windows, or to enable + virtualization in the machine's BIOS/UEFI settings, when what failed is + WSL 2's virtual machine starting up. + - **Work no longer offers to run a session in a workspace** — the new-session screen's "Local" chip creates a git worktree on the machine the session runs on, so in a sandbox it checked out into the sandbox's own disk: invisible diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 8ad19730a3..e1a02cdc38 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -204,7 +204,7 @@ from the squash message first, or use a plain merge. | `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. | | `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. | | `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). | -| 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; both return the same `WslCommandResult` shape, so 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//`) 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 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. | +| 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//`) 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. | | 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. | | 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. | | `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. | diff --git a/packages/codev-sandbox/src/wsl/cli.ts b/packages/codev-sandbox/src/wsl/cli.ts index 82c0604f41..1cf903ff22 100644 --- a/packages/codev-sandbox/src/wsl/cli.ts +++ b/packages/codev-sandbox/src/wsl/cli.ts @@ -56,6 +56,16 @@ export function runWsl( }) } +// wsl.exe reports failures on stdout, not stderr — localized text, usually +// UTF-16LE, which decodeWslOutput has already normalized by the time a result +// reaches this. An error message built from stderr alone therefore reduces a +// real diagnostic ("Error code: Wsl/Service/RegisterDistro/CreateVm/HCS/…") +// to a bare "exited 4294967295". +export function wslFailureDetail(result: WslResult) { + const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n") + return detail.length ? detail : `exited ${result.code}` +} + // `wsl --list --quiet` prints one distro per line; entries can carry a trailing // CR and the default distro is not marked in --quiet mode. export function parseDistroList(stdout: string) { diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index e58ff5b345..5debabfe78 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import type { SandboxConfig } from "../provider" -import { decodeWslOutput, parseDistroList, wslEnvSpec } from "./cli" +import { decodeWslOutput, parseDistroList, wslEnvSpec, wslFailureDetail } from "./cli" import { createWslProvider, wslDistroName, type WslProviderOptions } from "./provider" // tmpdir() rather than a literal /tmp so the suite also runs on the Windows @@ -107,6 +107,16 @@ test("wslEnvSpec marks every variable host-to-guest only", () => { expect(wslEnvSpec(["CODEV_SERVER_PORT", "HOME"])).toBe("CODEV_SERVER_PORT/u:HOME/u") }) +// wsl.exe reports failures on stdout, not stderr; a message built from stderr +// alone reduced a real diagnostic to a bare "exited 4294967295". +test("wslFailureDetail keeps the stdout diagnostic and falls back to the exit code", () => { + expect(wslFailureDetail({ code: 1, stdout: "", stderr: " bad \r\n" })).toBe("bad") + expect(wslFailureDetail({ code: null, stdout: "Error code: Wsl/Service/CreateVm\r\n", stderr: "" })).toBe( + "Error code: Wsl/Service/CreateVm", + ) + expect(wslFailureDetail({ code: 4294967295, stdout: "", stderr: "" })).toBe("exited 4294967295") +}) + test("availability refuses non-Windows hosts", async () => { const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) const provider = createWslProvider(await preparedOptions(dir, { platform: "darwin" })) @@ -208,6 +218,29 @@ test("create imports the distro once and is idempotent", async () => { await rm(dir, { recursive: true, force: true }) }) +// The failure a machine that installed WSL 2 but has not restarted shows: +// --import needs the WSL2 utility VM, wsl.exe puts the diagnostic on stdout in +// UTF-16LE, stderr stays empty, and the exit code is -1. +test("an import failure surfaces wsl.exe's stdout diagnostic and the restart hint", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const fake = join(dir, "fake-wsl-no-vm.mjs") + await writeFile( + fake, + `const out = (text) => process.stdout.write(Buffer.from(text, "utf16le")) +const args = process.argv.slice(2) +if (args[0] === "--list") { out("\\r\\n"); process.exit(0) } +if (args[0] === "--import") { + out("Error code: Wsl/Service/RegisterDistro/CreateVm/HCS/HCS_E_HYPERV_NOT_INSTALLED\\r\\n") + process.exit(255) +} +process.exit(1) +`, + ) + const provider = createWslProvider(await preparedOptions(dir, { wslCommand: fake })) + await expect(provider.create(config)).rejects.toThrow(/HCS_E_HYPERV_NOT_INSTALLED[^]*restart Windows/) + await rm(dir, { recursive: true, force: true }) +}) + test("create rebuilds a registered distro when the image moved on", async () => { const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) const provider = createWslProvider(await preparedOptions(dir)) diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index 4717bb79db..f2e9876d20 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -21,7 +21,15 @@ import type { SandboxJobOptions, SandboxProvider, } from "../provider" -import { parseDistroList, reserveLoopbackPort, runWsl, wslEnvSpec } from "./cli" +import { parseDistroList, reserveLoopbackPort, runWsl, wslEnvSpec, wslFailureDetail } from "./cli" + +// The HCS error family wsl.exe raises when the WSL2 utility VM cannot start: +// VirtualMachinePlatform enabled but inactive until Windows restarts, or +// virtualization switched off in firmware. 0x80370102 is the numeric form of +// HCS_E_HYPERV_NOT_INSTALLED on builds that print no symbolic name. +const WSL_VM_UNAVAILABLE = /0x80370102|0x80370114|HCS_E_HYPERV_NOT_INSTALLED|HCS_E_SERVICE_NOT_AVAILABLE/i +const WSL_VM_UNAVAILABLE_HINT = + "WSL 2 could not start its virtual machine. If WSL was just installed, restart Windows and try again; if it still fails, enable virtualization in the machine's BIOS/UEFI settings." // Guest-side paths. The bundle mount point and the launcher are baked into the // image, so they match the vz and bwrap backends exactly. @@ -93,7 +101,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider const translated = await wsl(["-d", distro, "--exec", "wslpath", "-a", "-u", windowsPath]) const path = translated.stdout.trim() if (translated.code !== 0 || !path.length) - throw new Error(`could not translate ${windowsPath} for the sandbox: ${translated.stderr.trim()}`) + throw new Error(`could not translate ${windowsPath} for the sandbox: ${wslFailureDetail(translated)}`) return path } @@ -105,9 +113,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider await wsl(["--terminate", distro]) const unregistered = await wsl(["--unregister", distro], { timeoutMs: WSL_UNREGISTER_TIMEOUT_MS }) if (unregistered.code !== 0) - throw new Error( - `wsl --unregister failed for ${distro}: ${unregistered.stderr.trim() || `exited ${unregistered.code}`}`, - ) + throw new Error(`wsl --unregister failed for ${distro}: ${wslFailureDetail(unregistered)}`) } await rm(wslSandboxDirs(options.dataDir, config.name).base, { recursive: true, force: true }) } @@ -127,9 +133,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider await wsl(["--terminate", distro]) const unregistered = await wsl(["--unregister", distro], { timeoutMs: WSL_UNREGISTER_TIMEOUT_MS }) if (unregistered.code !== 0) - throw new Error( - `wsl --unregister failed for ${distro}: ${unregistered.stderr.trim() || `exited ${unregistered.code}`}`, - ) + throw new Error(`wsl --unregister failed for ${distro}: ${wslFailureDetail(unregistered)}`) await rm(dirs.install, { recursive: true, force: true }) } const tarball = await stageAsset(options.cacheDir, asset, { @@ -141,8 +145,13 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider const imported = await wsl(["--import", distro, dirs.install, tarball, "--version", "2"], { timeoutMs: WSL_IMPORT_TIMEOUT_MS, }) - if (imported.code !== 0) - throw new Error(`wsl --import failed for ${distro}: ${imported.stderr.trim() || `exited ${imported.code}`}`) + if (imported.code !== 0) { + // Import is the first command that needs the WSL2 utility VM, so a + // machine that installed WSL but has not restarted yet fails here. + const detail = wslFailureDetail(imported) + const hint = WSL_VM_UNAVAILABLE.test(detail) ? ` ${WSL_VM_UNAVAILABLE_HINT}` : "" + throw new Error(`wsl --import failed for ${distro}: ${detail}${hint}`) + } // Windows has no uid to match, so the guest keeps its own codev user and // the Windows drives are automounted as that user — without this the // unprivileged server could not write the project directory. wsl.conf is @@ -158,9 +167,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider `printf '[automount]\\noptions = "metadata,uid=1000,gid=1000"\\n' > /etc/wsl.conf`, ]) if (configured.code !== 0) - throw new Error( - `could not write /etc/wsl.conf in ${distro}: ${configured.stderr.trim() || `exited ${configured.code}`}`, - ) + throw new Error(`could not write /etc/wsl.conf in ${distro}: ${wslFailureDetail(configured)}`) await wsl(["--terminate", distro]) await writeImageMarker(dirs.base, asset) } diff --git a/packages/desktop/src/main/sandbox/wsl-install.test.ts b/packages/desktop/src/main/sandbox/wsl-install.test.ts index 9f13e4f4be..b2c383d980 100644 --- a/packages/desktop/src/main/sandbox/wsl-install.test.ts +++ b/packages/desktop/src/main/sandbox/wsl-install.test.ts @@ -32,9 +32,25 @@ test("reports the download, then the elevated install, then success", async () = expect(installer.getState()).toEqual({ status: "installed" }) }) -// The elevated script maps 3010 (reboot required) to a clean exit, so a -// successful install says nothing about whether WSL can run yet. Re-probing is -// the only honest test. +// The elevated script re-raises 3010 (reboot required): VirtualMachinePlatform +// was just enabled and does not run until Windows restarts. The availability +// probe must not be consulted — `wsl --version` answers from the fresh MSI +// before the feature is active, so it would claim WSL is ready. +test("a reboot-required install reports restart-required without trusting availability", async () => { + const installer = createWslRuntimeInstaller({ + platform: "win32", + availability: async () => supported, + install: async () => ({ code: 3010, signal: null, stdout: "", stderr: "" }), + }) + + await installer.install() + + expect(installer.getState()).toEqual({ status: "restart-required" }) +}) + +// The fallback path (`wsl --install`) reports no reboot signal of its own, so +// a clean exit still says nothing about whether WSL can run yet. Re-probing is +// the backstop. test("a clean install that leaves WSL unusable reports restart-required", async () => { const installer = createWslRuntimeInstaller({ platform: "win32", diff --git a/packages/desktop/src/main/sandbox/wsl-install.ts b/packages/desktop/src/main/sandbox/wsl-install.ts index 00f9e28b0c..a2c7c2919a 100644 --- a/packages/desktop/src/main/sandbox/wsl-install.ts +++ b/packages/desktop/src/main/sandbox/wsl-install.ts @@ -1,5 +1,5 @@ import type { SandboxAvailability } from "@codev/sandbox" -import { installWslRuntimeDualPath } from "../wsl/install-bundle" +import { WSL_INSTALL_REBOOT_REQUIRED, installWslRuntimeDualPath } from "../wsl/install-bundle" import { summarize } from "../wsl/runtime" // Installing the WSL runtime on behalf of a sandbox. The Code side already @@ -98,17 +98,26 @@ export function createWslRuntimeInstaller(deps: { setState({ status: "failed", message: result.message }) return } + // Re-raised by the elevated script when dism or msiexec answered 3010: + // VirtualMachinePlatform was just enabled and does not run until Windows + // restarts. The availability probe below cannot catch this — `wsl + // --version` answers from the freshly installed MSI before the feature + // is active — so trusting it here reported "installed" on a machine + // whose first `wsl --import` could only fail. + if (result.code === WSL_INSTALL_REBOOT_REQUIRED) { + logger.log("WSL install finished; Windows must restart before WSL 2 can run") + setState({ status: "restart-required" }) + return + } if (result.code !== 0) { const message = summarize(result.stderr || result.stdout) || `WSL installation failed (exit ${result.code})` logger.error("WSL install failed", { code: result.code, message }) setState({ status: "failed", message }) return } - // Enabling VirtualMachinePlatform needs a reboot on a machine that never - // had it, and both dism and msiexec report that as success (3010, which - // the elevated script maps to 0). Whether WSL answers *now* is the only - // honest test, and it is the same one the Code side makes after its own - // install. + // Backstop for the fallback path (`wsl --install`), which reports no + // reboot signal of its own: ask WSL whether it answers now, the same + // test the Code side makes after its own install. const availability = await deps.availability() logger.log("WSL install finished", { supported: availability.supported }) setState(availability.supported ? { status: "installed" } : { status: "restart-required" }) diff --git a/packages/desktop/src/main/wsl/install-bundle.test.ts b/packages/desktop/src/main/wsl/install-bundle.test.ts index d0e6c6fffc..f4c3b7f353 100644 --- a/packages/desktop/src/main/wsl/install-bundle.test.ts +++ b/packages/desktop/src/main/wsl/install-bundle.test.ts @@ -203,13 +203,16 @@ test("fails loudly when the zip is missing the distro image", async () => { expect(ensureWslInstallBundle({ dir, fetchImpl })).rejects.toThrow(WSL_DISTRO_IMAGE_NAME) }) -test("elevated script tolerates reboot-required exits and quotes the MSI path", () => { +test("elevated script re-raises reboot-required exits and quotes the MSI path", () => { const script = wslRuntimeInstallScript(`C:\\Users\\O'Brien\\wsl.msi`) expect(script).toContain("/featurename:VirtualMachinePlatform") expect(script).toContain("'C:\\Users\\O''Brien\\wsl.msi'") // 3010 = ERROR_SUCCESS_REBOOT_REQUIRED, expected from both dism and msiexec. expect(script).toContain("($dism -ne 0) -and ($dism -ne 3010)") expect(script).toContain("($msi.ExitCode -ne 0) -and ($msi.ExitCode -ne 3010)") + // Re-raised, not mapped to 0: the exit code is the only signal that crosses + // the RunAs boundary, and no later wsl.exe probe can recover it. + expect(script).toContain("if (($dism -eq 3010) -or ($msi.ExitCode -eq 3010)) { exit 3010 }") }) test("only Ubuntu-24.04 is served from the bundle", () => { diff --git a/packages/desktop/src/main/wsl/install-bundle.ts b/packages/desktop/src/main/wsl/install-bundle.ts index af575478d4..e21d539cd9 100644 --- a/packages/desktop/src/main/wsl/install-bundle.ts +++ b/packages/desktop/src/main/wsl/install-bundle.ts @@ -124,10 +124,18 @@ export async function installWslRuntimeFromMsi(opts?: InstallBundleOptions): Pro return runPowerShell(command, { ...opts, timeoutMs: opts?.timeoutMs ?? INSTALL_TIMEOUT_MS }) } +// ERROR_SUCCESS_REBOOT_REQUIRED from dism and msiexec: the install worked but +// VirtualMachinePlatform does not run until Windows restarts. +export const WSL_INSTALL_REBOOT_REQUIRED = 3010 + // One elevated script covers both steps so the user sees a single UAC prompt. // 3010 is ERROR_SUCCESS_REBOOT_REQUIRED from both dism and msiexec — the -// expected outcome on a machine that never had VirtualMachinePlatform — and -// the pending-restart handling downstream already covers it. +// expected outcome on a machine that never had VirtualMachinePlatform. It is +// re-raised rather than mapped to 0: the exit code is the only thing that +// crosses the RunAs boundary (the elevated console's output is not captured), +// and no later probe can stand in for it — `wsl --version` answers from the +// freshly installed MSI before the feature is active, so it reports a working +// WSL on a machine whose first `wsl --import` can only fail. export function wslRuntimeInstallScript(msiPath: string) { return [ "$ErrorActionPreference = 'Continue'", @@ -136,6 +144,7 @@ export function wslRuntimeInstallScript(msiPath: string) { "if (($dism -ne 0) -and ($dism -ne 3010)) { exit $dism }", `$msi = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', ('"{0}"' -f ${psQuote(msiPath)}), '/qn', '/norestart') -Wait -PassThru`, "if (($msi.ExitCode -ne 0) -and ($msi.ExitCode -ne 3010)) { exit $msi.ExitCode }", + "if (($dism -eq 3010) -or ($msi.ExitCode -eq 3010)) { exit 3010 }", "exit 0", ].join("\r\n") } diff --git a/packages/desktop/src/main/wsl/servers.ts b/packages/desktop/src/main/wsl/servers.ts index edc08370c8..13b8b75f83 100644 --- a/packages/desktop/src/main/wsl/servers.ts +++ b/packages/desktop/src/main/wsl/servers.ts @@ -15,7 +15,7 @@ import { WSL_SERVERS_KEY } from "../store-keys" import { getStore } from "../store" import { expectCodevVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup" import { clearWslDistroState, wslServerIdToRestart } from "./policy" -import { installWslDistroDualPath, installWslRuntimeDualPath } from "./install-bundle" +import { WSL_INSTALL_REBOOT_REQUIRED, installWslDistroDualPath, installWslRuntimeDualPath } from "./install-bundle" import { installWslCodev, listInstalledWslDistros, @@ -325,6 +325,15 @@ export function createWslServersController( async installWsl() { await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => { const result = await installWslRuntimeDualPath({ signal: abort.signal }, logger) + // Re-raised by the elevated script: the install worked, but the just + // enabled VirtualMachinePlatform does not run until Windows restarts. + // The probe below cannot see that — `wsl --version` answers from the + // fresh MSI before the feature is active. + if (result.code === WSL_INSTALL_REBOOT_REQUIRED) { + const runtime = await probeWslRuntime({ signal: abort.signal }) + setState({ runtime, pendingRestart: true }) + return + } if (result.code !== 0) { const message = summarize(result.stderr || result.stdout) || "WSL installation failed" throw new Error(message) From 99e69b4e0d20ae092d940519d79e14f41f6b3278 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Wed, 19 Aug 2026 16:02:54 +0700 Subject: [PATCH 2/3] feat(desktop): offer to restart Windows when the WSL install needs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restart-required install left the user with a sentence and a trip to the Start menu. The create-sandbox dialogs now show a Restart Windows button in that state: it arms an inline confirm step — restarting closes CoDev and every other open application, and the hint says so — and then runs shutdown.exe /r /t 0 via a new sandbox-wsl-restart channel. No /f, so an application holding unsaved work can still hold the restart, and a refusal (shutdown policy, an already pending shutdown) surfaces as a toast instead of a restart that never comes. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++- DIVERGENCES.md | 2 +- packages/app/src/i18n/en.ts | 3 ++ packages/app/src/i18n/es.ts | 4 ++ packages/app/src/sandbox/types.ts | 3 ++ packages/app/src/sandbox/wsl-install.tsx | 44 +++++++++++++++++-- packages/desktop/src/main/sandbox/ipc.ts | 5 ++- .../src/main/sandbox/wsl-install.test.ts | 44 ++++++++++++++++++- .../desktop/src/main/sandbox/wsl-install.ts | 29 +++++++++++- packages/desktop/src/preload/index.ts | 1 + 10 files changed, 134 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35afd2a98b..33d56fc8ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,7 +96,12 @@ file is the human-readable summary of what changed and why. `wsl --import` and nothing to go on. The installer now recognizes Windows' restart-required answer and the dialog says to restart Windows and come back, in both the sandbox and the Code - side's WSL install. And when a WSL command fails anyway, the error now + side's WSL install. The create-sandbox dialogs also offer a **Restart + Windows** button right there, so you are not sent to the Start menu + mid-flow: it asks you to confirm first — restarting closes CoDev and every + other open application — and an application holding unsaved work can still + hold the restart, because Windows is asked to restart, not forced to. And + when a WSL command fails anyway, the error now carries WSL's own explanation — WSL prints its errors on the output stream the app wasn't reading — with a hint to restart Windows, or to enable virtualization in the machine's BIOS/UEFI settings, when what failed is diff --git a/DIVERGENCES.md b/DIVERGENCES.md index e1a02cdc38..ebfc93c7a7 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -221,7 +221,7 @@ from the squash message first, or use a plain merge. | New-session project picker on Work: `packages/app/src/pages/session/composer/session-composer-controls.ts`, `packages/app/src/components/{prompt-project-selector,app-sidebar}.tsx` | Extends the Code/Work split row above to the composer's project picker. `createPromptProjectControls` indexed `server.list` in four places — the grouped project list, the two server lookups behind select/add, and the `server` field the controls hand back — so a draft on the Work side listed the local machine's projects under a "Local Server" heading beside the sandbox's own, and picking one moved the session onto that server. All four read a `sideServers()` memo instead: `serversForSide(server.list, sideForServer(projectServer()))`, from the fork-only `src/workspace/side.ts`. The side comes from the draft's **own** server (`serverSDK().server`) rather than `useWorkspaceSide()`, so a draft always offers what it can actually run on whichever side happens to be showing. Nothing else changes: with one server on the side the picker takes the same single-server branch Code has always taken (`layout.projects.list()`, or the draft's server ctx for a draft route), which drops the group headings and the "Add project" submenu the way it does for a lone local server. **Add project on Work is a mount**: `addProject` opens the fork-only `DialogSandboxMounts` for that sandbox instead of `pickDirectory`, through the fork-only `src/sandbox/mount-dialog.tsx` — upstream's picker browses the server it is pointed at, which for a sandbox is the guest's own filesystem. `PromptProjectControls` gains an optional `mount` flag and `prompt-project-selector.tsx` reads it in two label getters (`labels.add`, `labels.new`) so the item says "Mount project" (`sandbox.server.mounts`, the string the Work sidebar's button already uses); nothing else in that component changes. `app-sidebar.tsx`'s Work branch and the fork-only `mount-prompt.tsx` both route through the same hook rather than repeating the lazy import. | The two sides exist so that "am I on my machine or in a sandbox?" is a property of where you are. A picker that offers both breaks that in the one place it matters most — the moment a session is created — and the failure is silent: the session opens on the local machine with no sandbox isolation, from a screen the user reached through the Work tab. | Keep the filter and take upstream's surrounding changes. If upstream adds a fifth `server.list` read here, filter it too. Read the side from `sideForServer(projectServer())`, not `useWorkspaceSide()` — a draft route mounts its own server (`ResolvedDraftRoute` in `app.tsx`), and the sidebar's side is not guaranteed to agree with it. The `sideServers().length <= 1` branch must stay a length test on the *side* list, not on `server.list`: on a machine with one sandbox and one local server the old test sent Work down the multi-server path. | | `packages/app/src/context/server.tsx` | `createServerProjects.close()` wraps its two store writes — `remove(directory)` on `projects`, then the `recentlyClosed` record — in `batch()` (already imported). The recently-closed list is computed before the batch so the pair is one update. | The two writes are separate store keys and Solid flushes effects after each one, so between them a project is in neither list. Nothing upstream observes that gap, but the fork's `src/sandbox/projects.ts` seeding effect reconciles against **both** lists, saw a mounted folder that was neither open nor closed, and opened it straight back up — closing a Work folder was undone the instant it happened. | Keep the batch. On conflict take upstream's `close()` and re-wrap the two writes. Any future write pair that spans `projects` and `recentlyClosed` needs the same treatment; regression cover is `packages/app/test-browser/sandbox-projects.test.ts` ("closing a mounted folder sticks when the guest knows the project"). | -| WSL auto-install from the sandbox dialogs: `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | i18n gains the `sandbox.wsl.*` family (install action/retry, the permission-and-size hint, download progress with and without a content-length, the elevated step, restart-required, and the failure line). preload exposes four channels on the existing `api.sandboxes` object — `sandbox-wsl-install{,-cancel,-state}` and a `sandbox-wsl-install-subscribe`/`-event` pair — and re-exports `WslInstallState`. All the logic is fork-only: `src/main/sandbox/wsl-install.ts` (the state machine), `src/main/wsl/install-bundle.ts` (manifest-driven per-arch MSI acquisition), `src/app/sandbox/wsl-install.tsx` (the shared UI both create-sandbox dialogs render in place of their form). | A Windows machine without WSL could not create a sandbox and was told to go install it by hand — while the app already shipped an installer for the Code side's WSL servers. The sandbox needs only the runtime (it imports its own rootfs and mounts its own server), so this reuses `installWslRuntimeDualPath` and skips the distro and CoDev-in-distro steps entirely. Its own channel set rather than a second `sandbox-event` type because this is platform state that exists before any sandbox does, and it lives in main rather than in the dialog because a ~250MB download on a whitelisted-proxy link must survive the dialog closing. | Keep the key family and the four channels; take upstream's surrounding changes. The channel names are preload/renderer contract — renaming one side alone breaks the dialogs silently. If upstream reworks `api.sandboxes`, keep the install methods on it rather than promoting them to a new top-level namespace: the renderer reaches them through `platform.sandboxes`. | +| WSL auto-install from the sandbox dialogs: `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | i18n gains the `sandbox.wsl.*` family (install action/retry, the permission-and-size hint, download progress with and without a content-length, the elevated step, restart-required, the restart-Windows action with its confirm step, and the failure line). preload exposes five channels on the existing `api.sandboxes` object — `sandbox-wsl-install{,-cancel,-state}`, a `sandbox-wsl-install-subscribe`/`-event` pair, and `sandbox-wsl-restart` (runs `shutdown.exe /r /t 0` for the restart a restart-required install asks for; the renderer confirms first) — and re-exports `WslInstallState`. All the logic is fork-only: `src/main/sandbox/wsl-install.ts` (the state machine), `src/main/wsl/install-bundle.ts` (manifest-driven per-arch MSI acquisition), `src/app/sandbox/wsl-install.tsx` (the shared UI both create-sandbox dialogs render in place of their form). | A Windows machine without WSL could not create a sandbox and was told to go install it by hand — while the app already shipped an installer for the Code side's WSL servers. The sandbox needs only the runtime (it imports its own rootfs and mounts its own server), so this reuses `installWslRuntimeDualPath` and skips the distro and CoDev-in-distro steps entirely. Its own channel set rather than a second `sandbox-event` type because this is platform state that exists before any sandbox does, and it lives in main rather than in the dialog because a ~250MB download on a whitelisted-proxy link must survive the dialog closing. | Keep the key family and the five channels; take upstream's surrounding changes. The channel names are preload/renderer contract — renaming one side alone breaks the dialogs silently. If upstream reworks `api.sandboxes`, keep the install methods on it rather than promoting them to a new top-level namespace: the renderer reaches them through `platform.sandboxes`. | | Update checking and download controls: `packages/app/src/updater.ts`, `packages/app/src/components/{updater-action.ts,settings-general.tsx}`, `packages/app/src/pages/layout.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/main/{updater.ts,updater-controller.ts,index.ts,ipc.ts}`, `packages/desktop/src/preload/{index,types}.ts` | `UpdaterState` gains `available` (found, not fetched) and the shared `UpdaterPrefs` (`autoCheck`, `autoDownload`, both defaulting true — upstream's behaviour). The controller takes `initialPrefs`/`savePrefs`, stops a check at `available` when `autoDownload` is off, gains `download()` for the consent step, gains `getPrefs`/`setPrefs`/`subscribePrefs`, skips the launch check when `autoCheck` is off, and short-circuits a repeat check while `available`. `main/updater.ts` reads and writes the prefs in the existing `opencode.updater` store under a `prefs` key; `main/index.ts`'s ten-minute timer consults `getPrefs().autoCheck` at fire time; `ipc.ts` adds `updater-download` and `updater-set-prefs` and registers a second listener on the existing `updater-subscribe` so prefs broadcast to every window; preload exposes `download`, `setPrefs`, `subscribePrefs`. The renderer adds two `SettingsRow` switches guarded by `platform.updater` (the Updates section is not desktop-gated, so the web build must not render them), the toast offers Download when the update is not on disk, and the titlebar pill deliberately stays `ready`-only — it installs, and an undownloaded update cannot be installed. i18n drops the dead `settings.updates.row.startup.*` keys (translated, never rendered) for `settings.updates.row.{autoCheck,autoDownload}.*`, `settings.updates.action.download` and `toast.update.description.available`. | Upstream checks and downloads unconditionally. The fleet is on metered, whitelist-only links where a several-hundred-megabyte installer arriving unannounced is a real cost, and the app already shipped the copy for a setting it never built. Prefs live in the main process because that is where the timer runs; putting them in the renderer's settings store would have meant main parsing the renderer's persisted format. | Keep the `available` state and the prefs plumbing together — the state is unreachable without `autoDownload`, and the setting is meaningless without the state. Take upstream's other changes to these files. If upstream reworks `createUpdaterController`, re-apply the two gates (`autoCheck` in `start()`, `autoDownload` in `check()`) rather than the whole diff; both are covered by `updater-controller.test.ts`. Defaults must stay true on both — a machine that never opens Settings has to behave exactly as it did before. | | `packages/app/src/context/global.tsx` | Two lines: imports `seedSandboxProjects` from the fork-only `packages/app/src/sandbox/projects.ts` and calls it with `server.list` + `ensureServerCtx`, beside the memo that already creates a ctx per connection. All the logic lives in the fork-only module. | A sandbox exposes exactly one directory (`/workspace` on vz/bwrap, the wslpath translation on WSL) and the guest registers it as its own project, but the app's per-server project list starts empty and nothing ever fills it. With no project, every "New session" entry point — `home-model.openNewSession`, the titlebar's `openNewTab` fallback chain — finds nothing for that server and falls back to another one, so the sandbox looks selected while the session runs on the local server. The seed reads `sync.data.path.directory`, which every server ctx already fetches, so it stays backend-agnostic. The same module also names that project after the sandbox's host folder (via `client.project.update`, the call behind rename-project), because the guest sees only its mount point and every sandbox would otherwise read "workspace" in the sidebar, tabs and palette. | Keep both lines; take upstream's other changes to the file. The call must stay inside the Global context init (it needs `ensureServerCtx`) — `SandboxesProvider` is mounted above `GlobalProvider` in `app.tsx`, so it cannot live there. | diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c9ed48a507..2dd2e3bb3b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -400,6 +400,9 @@ export const dict = { "sandbox.wsl.downloading": "Downloading WSL... {{done}} MB of {{total}} MB", "sandbox.wsl.installing": "Installing WSL. Approve the Windows prompt to continue.", "sandbox.wsl.restartRequired": "WSL is installed. Restart Windows to finish, then set up your sandbox.", + "sandbox.wsl.restartAction": "Restart Windows", + "sandbox.wsl.restartConfirm": "Restart now", + "sandbox.wsl.restartConfirmHint": "Windows will close CoDev and every other open application. Save your work first.", "sandbox.wsl.failed": "WSL could not be installed: {{message}}", "sandbox.mountPrompt.body": "Your sandbox is ready. Mount a project to start working in it.", "sandbox.mountPrompt.action": "Mount a project", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index c7cb4c653b..3d805ba27a 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -390,6 +390,10 @@ export const dict = { "sandbox.wsl.downloading": "Descargando WSL... {{done}} MB de {{total}} MB", "sandbox.wsl.installing": "Instalando WSL. Acepta el aviso de Windows para continuar.", "sandbox.wsl.restartRequired": "WSL está instalado. Reinicia Windows para terminar y luego configura tu sandbox.", + "sandbox.wsl.restartAction": "Reiniciar Windows", + "sandbox.wsl.restartConfirm": "Reiniciar ahora", + "sandbox.wsl.restartConfirmHint": + "Windows cerrará CoDev y todas las demás aplicaciones abiertas. Guarda tu trabajo primero.", "sandbox.wsl.failed": "No se pudo instalar WSL: {{message}}", "sandbox.mountPrompt.body": "Tu sandbox está listo. Monta un proyecto para empezar a trabajar en él.", "sandbox.mountPrompt.action": "Montar un proyecto", diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts index f37b5c3391..360b1d0532 100644 --- a/packages/app/src/sandbox/types.ts +++ b/packages/app/src/sandbox/types.ts @@ -116,6 +116,9 @@ export type SandboxesPlatform = { // (needs a restart, failed) are not exceptions. installWsl(): Promise cancelWslInstall(): Promise + // Restarts Windows, for an install that ended in restart-required. The + // caller confirms with the user first: this closes every open application. + restartWindows(): Promise wslInstallState(): Promise subscribeWslInstall(cb: (state: WslInstallState) => void): () => void // Resolves undefined when the create was cancelled mid-download. diff --git a/packages/app/src/sandbox/wsl-install.tsx b/packages/app/src/sandbox/wsl-install.tsx index 061b4fd135..1123d66018 100644 --- a/packages/app/src/sandbox/wsl-install.tsx +++ b/packages/app/src/sandbox/wsl-install.tsx @@ -1,7 +1,7 @@ import { showToast } from "@opencode-ai/ui/toast" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2" -import { Match, Show, Switch, createEffect } from "solid-js" +import { Match, Show, Switch, createEffect, createSignal } from "solid-js" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSandboxes } from "./context" @@ -54,6 +54,22 @@ export function SandboxWslInstall(props: { message: string }) { }) } + // Restarting closes every open application, so the button arms a confirm + // step instead of firing; a refusal (shutdown policy, a pending shutdown) + // comes back as a rejection and disarms it. + const [confirmRestart, setConfirmRestart] = createSignal(false) + const restart = () => { + if (!api) return + api.restartWindows().catch((error: unknown) => { + setConfirmRestart(false) + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + }) + } + return (

{props.message}

@@ -85,8 +101,8 @@ export function SandboxWslInstall(props: { message: string }) {

{language.t("sandbox.wsl.installing")}

- {/* Terminal: nothing this app can do next, and running the installer - again would not change it. */} + {/* The install is done; only Windows restarting finishes it, so the + one thing left to offer is doing that. */}

{language.t("sandbox.wsl.restartRequired")}

@@ -96,6 +112,28 @@ export function SandboxWslInstall(props: { message: string }) { )} + + + setConfirmRestart(true)}> + {language.t("sandbox.wsl.restartAction")} + +
+ } + > +

{language.t("sandbox.wsl.restartConfirmHint")}

+
+ + {language.t("sandbox.wsl.restartConfirm")} + + setConfirmRestart(false)}> + {language.t("common.cancel")} + +
+ +
diff --git a/packages/desktop/src/main/sandbox/ipc.ts b/packages/desktop/src/main/sandbox/ipc.ts index b328bac35f..c243faae29 100644 --- a/packages/desktop/src/main/sandbox/ipc.ts +++ b/packages/desktop/src/main/sandbox/ipc.ts @@ -11,7 +11,7 @@ import { import type { MountValidationError, SandboxImageInspection } from "@codev/sandbox" import type { DesktopSandboxController } from "./controller" import { DEFAULT_SANDBOX_NAME, createSandboxWorkspaces } from "./workspace" -import { createWslRuntimeInstaller } from "./wsl-install" +import { createWslRuntimeInstaller, restartWindows } from "./wsl-install" import { write } from "../logging" // Unlike the WSL channels there is no unavailable-stub variant: the controller @@ -96,6 +96,9 @@ export function registerSandboxIpcHandlers(controller: DesktopSandboxController) ipcMain.handle("sandbox-wsl-install-state", () => wslInstall.getState()) ipcMain.handle("sandbox-wsl-install", () => wslInstall.install()) ipcMain.handle("sandbox-wsl-install-cancel", () => wslInstall.cancel()) + // The restart a restart-required install asks for. The renderer confirms + // before invoking; here there is nothing further to gate on. + ipcMain.handle("sandbox-wsl-restart", () => restartWindows()) // Inspects a candidate image so the dialog can report the problem while the // user is still filling the form. Returns the same problem shape the add // path enforces, so the two can never disagree about what is acceptable. diff --git a/packages/desktop/src/main/sandbox/wsl-install.test.ts b/packages/desktop/src/main/sandbox/wsl-install.test.ts index b2c383d980..213f3c81ff 100644 --- a/packages/desktop/src/main/sandbox/wsl-install.test.ts +++ b/packages/desktop/src/main/sandbox/wsl-install.test.ts @@ -1,7 +1,9 @@ import { expect, test } from "bun:test" +import type { spawn } from "node:child_process" +import { EventEmitter } from "node:events" import type { SandboxAvailability } from "@codev/sandbox" import type { WslInstallState } from "./wsl-install" -import { createWslRuntimeInstaller } from "./wsl-install" +import { createWslRuntimeInstaller, restartWindows } from "./wsl-install" const supported: SandboxAvailability = { supported: true, warning: null } const unsupported: SandboxAvailability = { supported: false, message: "WSL 2 is not installed." } @@ -152,3 +154,43 @@ test("refuses to run anywhere but Windows", async () => { expect(installer.install()).rejects.toThrow("Windows") }) + +function fakeShutdown(code: number, stderr = "") { + const calls: string[][] = [] + const spawnImpl = ((command: string, args: string[]) => { + calls.push([command, ...args]) + const child = Object.assign(new EventEmitter(), { stderr: new EventEmitter() }) + queueMicrotask(() => { + if (stderr) child.stderr.emit("data", stderr) + child.emit("close", code) + }) + return child + }) as unknown as typeof spawn + return { calls, spawnImpl } +} + +test("restartWindows asks shutdown.exe for an immediate restart", async () => { + const shutdown = fakeShutdown(0) + + await restartWindows({ platform: "win32", spawnImpl: shutdown.spawnImpl }) + + expect(shutdown.calls).toHaveLength(1) + expect(shutdown.calls[0]?.[0]).toEndWith("shutdown.exe") + expect(shutdown.calls[0]?.slice(1)).toEqual(["/r", "/t", "0"]) +}) + +// shutdown.exe refuses when policy forbids it or a shutdown is already +// pending; the dialog reports that rather than sitting on a restart that will +// never come. +test("restartWindows surfaces shutdown.exe's refusal", async () => { + const shutdown = fakeShutdown(1190, "A system shutdown has already been scheduled.\r\n") + + expect(restartWindows({ platform: "win32", spawnImpl: shutdown.spawnImpl })).rejects.toThrow("already been scheduled") +}) + +test("restartWindows refuses to run anywhere but Windows", async () => { + const shutdown = fakeShutdown(0) + + expect(restartWindows({ platform: "linux", spawnImpl: shutdown.spawnImpl })).rejects.toThrow("Windows") + expect(shutdown.calls).toHaveLength(0) +}) diff --git a/packages/desktop/src/main/sandbox/wsl-install.ts b/packages/desktop/src/main/sandbox/wsl-install.ts index a2c7c2919a..c5721b3d7e 100644 --- a/packages/desktop/src/main/sandbox/wsl-install.ts +++ b/packages/desktop/src/main/sandbox/wsl-install.ts @@ -1,6 +1,7 @@ +import { spawn } from "node:child_process" import type { SandboxAvailability } from "@codev/sandbox" import { WSL_INSTALL_REBOOT_REQUIRED, installWslRuntimeDualPath } from "../wsl/install-bundle" -import { summarize } from "../wsl/runtime" +import { resolveSystem32Command, summarize } from "../wsl/runtime" // Installing the WSL runtime on behalf of a sandbox. The Code side already // does this for WSL servers through the same dual path (bundled MSI first, @@ -27,6 +28,32 @@ type InstallLogger = { error: (message: string, meta?: unknown) => void } +// The restart an install that ended in restart-required asks for, offered as a +// button so the user is not sent to the Start menu mid-flow. No /f: without it +// an application with unsaved work gets to hold the restart and Windows shows +// who is holding it, where forcing would discard that work silently. A refusal +// (shutdown policy, an already pending shutdown) rejects so the dialog can say +// so. +export function restartWindows(deps?: { platform?: NodeJS.Platform; spawnImpl?: typeof spawn }) { + if ((deps?.platform ?? process.platform) !== "win32") + return Promise.reject(new Error("restarting Windows is only available on Windows")) + return new Promise((resolve, reject) => { + const child = (deps?.spawnImpl ?? spawn)(resolveSystem32Command("shutdown.exe"), ["/r", "/t", "0"], { + stdio: ["ignore", "ignore", "pipe"], + windowsHide: true, + }) + let stderr = "" + child.stderr?.on("data", (chunk) => { + stderr += chunk + }) + child.once("error", reject) + child.once("close", (code) => { + if (code === 0) return resolve() + reject(new Error(summarize(stderr) || `shutdown.exe exited ${code}`)) + }) + }) +} + export type WslRuntimeInstaller = ReturnType // Takes its platform and its installer rather than reading them, so the diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 6b35ed4bcb..7493041747 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -50,6 +50,7 @@ const api: ElectronAPI = { availability: () => ipcRenderer.invoke("sandbox-availability"), installWsl: () => ipcRenderer.invoke("sandbox-wsl-install"), cancelWslInstall: () => ipcRenderer.invoke("sandbox-wsl-install-cancel"), + restartWindows: () => ipcRenderer.invoke("sandbox-wsl-restart"), wslInstallState: () => ipcRenderer.invoke("sandbox-wsl-install-state"), subscribeWslInstall: (cb) => { const handler = (_: unknown, state: WslInstallState) => cb(state) From 7bf336190f76b828e81e8fe8eedcfd7964a5b413 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Wed, 19 Aug 2026 16:22:56 +0700 Subject: [PATCH 3/3] fix(desktop): enable the WSL component Windows 10 requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 10 +++++- DIVERGENCES.md | 2 +- .../codev-sandbox/src/wsl/provider.test.ts | 32 ++++++++++++++++--- packages/codev-sandbox/src/wsl/provider.ts | 17 ++++++++-- .../desktop/src/main/sandbox/wsl-install.ts | 3 +- .../src/main/wsl/install-bundle.test.ts | 10 ++++-- .../desktop/src/main/wsl/install-bundle.ts | 24 ++++++++++---- 7 files changed, 77 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33d56fc8ff..b50ebeafd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,7 +100,15 @@ file is the human-readable summary of what changed and why. Windows** button right there, so you are not sent to the Start menu mid-flow: it asks you to confirm first — restarting closes CoDev and every other open application — and an application holding unsaved work can still - hold the restart, because Windows is asked to restart, not forced to. And + hold the restart, because Windows is asked to restart, not forced to. + Windows 10 needed one more thing the installer never enabled: the Windows + Subsystem for Linux component itself, which Windows 10 requires even for + the standalone WSL the app installs — so a Windows 10 machine failed the + same way even after restarting. The installer enables it now, and the + "is WSL ready" check behind the dialogs asks WSL to actually stand up + (`wsl --status`) rather than merely answer a version query, so a machine + in that half-installed state is offered the (now complete) installer + instead of a doomed setup. And when a WSL command fails anyway, the error now carries WSL's own explanation — WSL prints its errors on the output stream the app wasn't reading — with a hint to restart Windows, or to enable diff --git a/DIVERGENCES.md b/DIVERGENCES.md index ebfc93c7a7..43f15bb166 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -204,7 +204,7 @@ from the squash message first, or use a plain merge. | `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. | | `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. | | `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). | -| 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//`) 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. | +| 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//`) 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. | | 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. | | 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. | | `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. | diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index 5debabfe78..903f05edd6 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -25,6 +25,7 @@ const args = process.argv.slice(2) const out = (text) => process.stdout.write(Buffer.from(text, "utf16le")) if (args[0] === "--version") { out("WSL version: 2.3.24.0\\r\\n"); process.exit(0) } +if (args[0] === "--status") { out("Default Version: 2\\r\\n"); process.exit(0) } if (args[0] === "--list") { out(read().distros.join("\\r\\n") + "\\r\\n"); process.exit(0) } if (args[0] === "--import") { 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 () => { await rm(dir, { recursive: true, force: true }) }) -test("availability reports unsupported when wsl --version fails", async () => { +// The machine that shaped the --status probe: the MSI is installed, so +// --version passes, but Windows 10 without the WSL optional component cannot +// run a distro — and --status names the missing piece. +test("availability surfaces why an installed WSL cannot run", async () => { const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) - const missing = join(dir, "missing-wsl.mjs") - await writeFile(missing, `process.exit(1)`) - const provider = createWslProvider(await preparedOptions(dir, { wslCommand: missing })) + const fake = join(dir, "fake-wsl-status.mjs") + await writeFile( + fake, + `const out = (text) => process.stdout.write(Buffer.from(text, "utf16le")) +if (process.argv[2] === "--version") { out("WSL version: 2.3.24.0\\r\\n"); process.exit(0) } +out("This application requires the Windows Subsystem for Linux Optional Component.\\r\\nError code: Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED\\r\\n") +process.exit(1) +`, + ) + const provider = createWslProvider(await preparedOptions(dir, { wslCommand: fake })) + const availability = await provider.availability() + expect(availability.supported).toBe(false) + if (!availability.supported) expect(availability.message).toContain("WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED") + await rm(dir, { recursive: true, force: true }) +}) + +// wsl.exe missing entirely (spawn fails): the one case with no wsl.exe output +// to show, so the generic install prompt is the message. +test("availability reports unsupported when wsl.exe is not there at all", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider( + await preparedOptions(dir, { wslCommand: join(dir, "missing-wsl.exe"), spawnImpl: spawn }), + ) const availability = await provider.availability() expect(availability.supported).toBe(false) expect(availability.supported === false && availability.message).toContain("WSL 2 is not installed") diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index f2e9876d20..ab99f7a3a9 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -177,12 +177,23 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider async availability(): Promise { if (platform !== "win32") return { supported: false, message: "WSL sandboxes require Windows" } - const version = await wsl(["--version"]).catch(() => null) - if (!version || version.code !== 0) + // --status, not --version: --version answers from the installed MSI + // alone and passed on machines where WSL could not run — a restart + // still pending, or Windows 10 without the WSL optional component + // (observed live as Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED while + // --version reported 2.x). --status fails, and says why, exactly when + // running a distro would; its reason is worth showing over a generic + // line, and the dialogs offer the app's own installer either way. + const status = await wsl(["--status"]).catch(() => null) + if (!status || status.code !== 0) { + const detail = status ? wslFailureDetail(status) : null return { supported: false, - message: "WSL 2 is not installed. Install it to use the sandbox, then try again.", + message: detail + ? `WSL 2 is not ready: ${detail}` + : "WSL 2 is not installed. Install it to use the sandbox, then try again.", } + } const build = windowsBuildNumber(osRelease()) // Only asked when a warning is actually in play. Every Windows 11 build // is above every floor Microsoft has published for WSL, so on the common diff --git a/packages/desktop/src/main/sandbox/wsl-install.ts b/packages/desktop/src/main/sandbox/wsl-install.ts index c5721b3d7e..0b9ca1d671 100644 --- a/packages/desktop/src/main/sandbox/wsl-install.ts +++ b/packages/desktop/src/main/sandbox/wsl-install.ts @@ -143,8 +143,7 @@ export function createWslRuntimeInstaller(deps: { return } // Backstop for the fallback path (`wsl --install`), which reports no - // reboot signal of its own: ask WSL whether it answers now, the same - // test the Code side makes after its own install. + // reboot signal of its own: ask WSL whether it answers now. const availability = await deps.availability() logger.log("WSL install finished", { supported: availability.supported }) setState(availability.supported ? { status: "installed" } : { status: "restart-required" }) diff --git a/packages/desktop/src/main/wsl/install-bundle.test.ts b/packages/desktop/src/main/wsl/install-bundle.test.ts index f4c3b7f353..4a0ac6b1c0 100644 --- a/packages/desktop/src/main/wsl/install-bundle.test.ts +++ b/packages/desktop/src/main/wsl/install-bundle.test.ts @@ -206,13 +206,17 @@ test("fails loudly when the zip is missing the distro image", async () => { test("elevated script re-raises reboot-required exits and quotes the MSI path", () => { const script = wslRuntimeInstallScript(`C:\\Users\\O'Brien\\wsl.msi`) expect(script).toContain("/featurename:VirtualMachinePlatform") + // Windows 10 requires the WSL optional component even for the standalone + // MSI (Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED without it). + expect(script).toContain("/featurename:Microsoft-Windows-Subsystem-Linux") expect(script).toContain("'C:\\Users\\O''Brien\\wsl.msi'") - // 3010 = ERROR_SUCCESS_REBOOT_REQUIRED, expected from both dism and msiexec. - expect(script).toContain("($dism -ne 0) -and ($dism -ne 3010)") + // 3010 = ERROR_SUCCESS_REBOOT_REQUIRED, expected from dism and msiexec. + expect(script).toContain("($vmp -ne 0) -and ($vmp -ne 3010)") + expect(script).toContain("($wsl -ne 0) -and ($wsl -ne 3010)") expect(script).toContain("($msi.ExitCode -ne 0) -and ($msi.ExitCode -ne 3010)") // Re-raised, not mapped to 0: the exit code is the only signal that crosses // the RunAs boundary, and no later wsl.exe probe can recover it. - expect(script).toContain("if (($dism -eq 3010) -or ($msi.ExitCode -eq 3010)) { exit 3010 }") + expect(script).toContain("if (($vmp -eq 3010) -or ($wsl -eq 3010) -or ($msi.ExitCode -eq 3010)) { exit 3010 }") }) test("only Ubuntu-24.04 is served from the bundle", () => { diff --git a/packages/desktop/src/main/wsl/install-bundle.ts b/packages/desktop/src/main/wsl/install-bundle.ts index e21d539cd9..43813fb93b 100644 --- a/packages/desktop/src/main/wsl/install-bundle.ts +++ b/packages/desktop/src/main/wsl/install-bundle.ts @@ -128,23 +128,33 @@ export async function installWslRuntimeFromMsi(opts?: InstallBundleOptions): Pro // VirtualMachinePlatform does not run until Windows restarts. export const WSL_INSTALL_REBOOT_REQUIRED = 3010 -// One elevated script covers both steps so the user sees a single UAC prompt. +// One elevated script covers every step so the user sees a single UAC prompt. +// Both Windows features are enabled, not just VirtualMachinePlatform: on +// Windows 10 the standalone MSI still requires the Microsoft-Windows- +// Subsystem-Linux optional component (observed live as `wsl --status` failing +// with Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED while `wsl --version` +// reported 2.x), and on a machine that already has it dism exits 0 without +// touching anything, so there is nothing to gate on the Windows build. +// // 3010 is ERROR_SUCCESS_REBOOT_REQUIRED from both dism and msiexec — the -// expected outcome on a machine that never had VirtualMachinePlatform. It is +// expected outcome on a machine that never had these features. It is // re-raised rather than mapped to 0: the exit code is the only thing that // crosses the RunAs boundary (the elevated console's output is not captured), // and no later probe can stand in for it — `wsl --version` answers from the -// freshly installed MSI before the feature is active, so it reports a working -// WSL on a machine whose first `wsl --import` can only fail. +// freshly installed MSI before the features are active, so it reports a +// working WSL on a machine whose first `wsl --import` can only fail. export function wslRuntimeInstallScript(msiPath: string) { return [ "$ErrorActionPreference = 'Continue'", "& dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart", - "$dism = $LASTEXITCODE", - "if (($dism -ne 0) -and ($dism -ne 3010)) { exit $dism }", + "$vmp = $LASTEXITCODE", + "if (($vmp -ne 0) -and ($vmp -ne 3010)) { exit $vmp }", + "& dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart", + "$wsl = $LASTEXITCODE", + "if (($wsl -ne 0) -and ($wsl -ne 3010)) { exit $wsl }", `$msi = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', ('"{0}"' -f ${psQuote(msiPath)}), '/qn', '/norestart') -Wait -PassThru`, "if (($msi.ExitCode -ne 0) -and ($msi.ExitCode -ne 3010)) { exit $msi.ExitCode }", - "if (($dism -eq 3010) -or ($msi.ExitCode -eq 3010)) { exit 3010 }", + "if (($vmp -eq 3010) -or ($wsl -eq 3010) -or ($msi.ExitCode -eq 3010)) { exit 3010 }", "exit 0", ].join("\r\n") }