From c619cb27bd259f4b9967c40bc7ba4915f3c96307 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 18 Aug 2026 16:44:50 +0700 Subject: [PATCH 1/3] feat(app): install WSL from the create-sandbox dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sandbox on Windows runs inside WSL 2, and a machine without it hit a dead end: the dialog reported "WSL 2 is not installed", offered nothing but Cancel, and told the user to install it and try again — which could not work either, since availability is answered once per app run and cached forever. Both create-sandbox dialogs now offer Install WSL. The runtime is downloaded as a standalone per-architecture MSI from the codev storage host and installed behind the existing single UAC prompt, falling back to Microsoft's installer when that host cannot be reached. ARM64 is covered for the first time; the bundled wsl.zip is x64-only. Nothing about the install itself is new: the elevated dism + msiexec script, the 3010 reboot-required handling and the acquisition-only fallback policy are the same code the Code side's WSL servers already use. What changed is where the MSI comes from, so that side gets ARM64 support and a 250MB download in place of 643MB for free. The two paths share one cache directory and filename deliberately — on x64 it is the same file, so whichever ran first, the other downloads nothing. Install state lives in the main process rather than in the dialog: the download is hundreds of megabytes on links measured in tens of KB/s, and closing the dialog must not lose track of it. After a clean install the sandbox availability probe runs again, because the elevated script maps 3010 to success and only the probe distinguishes installed from needs-a-reboot. That probe's cached verdict is now invalidated on completion, so the dialog carries straight on to creating the sandbox instead of repeating the message. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 15 ++ DIVERGENCES.md | 3 +- packages/app/src/i18n/en.ts | 9 ++ packages/app/src/i18n/es.ts | 8 + packages/app/src/sandbox/context.tsx | 29 +++- .../app/src/sandbox/dialog-add-default.tsx | 3 +- packages/app/src/sandbox/dialog-add.tsx | 3 +- packages/app/src/sandbox/types.ts | 23 +++ packages/app/src/sandbox/wsl-install.tsx | 133 +++++++++++++++++ packages/codev-sandbox/src/const.ts | 33 ++++- packages/desktop/src/main/sandbox/ipc.ts | 43 ++++++ .../src/main/sandbox/wsl-install.test.ts | 138 ++++++++++++++++++ .../desktop/src/main/sandbox/wsl-install.ts | 117 +++++++++++++++ .../src/main/wsl/install-bundle.test.ts | 96 +++++++++++- .../desktop/src/main/wsl/install-bundle.ts | 115 ++++++++++++--- packages/desktop/src/preload/index.ts | 14 +- packages/desktop/src/preload/types.ts | 1 + 17 files changed, 753 insertions(+), 30 deletions(-) create mode 100644 packages/app/src/sandbox/wsl-install.tsx create mode 100644 packages/desktop/src/main/sandbox/wsl-install.test.ts create mode 100644 packages/desktop/src/main/sandbox/wsl-install.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4f018988..cfa82cb950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,21 @@ file is the human-readable summary of what changed and why. ### Sandbox +- **CoDev installs WSL for you when a sandbox needs it** — a sandbox on Windows + runs inside WSL 2, and a computer without it got a dead end: the create-sandbox + dialog said WSL was not installed, offered nothing but **Cancel**, and told you + to install it yourself and try again — which did not work either, because the + answer was cached until the app restarted. Both create-sandbox dialogs now + offer **Install WSL** and do it for you: CoDev downloads the installer for your + computer's processor, asks Windows for permission once, and enables the + Virtual Machine Platform feature in the same step. You see the download's + progress, then the install, and the dialog carries straight on to setting up + the sandbox when it finishes — or says to restart Windows first when that is + what WSL needs. The installer comes from the CoDev storage host, so it works + on networks that cannot reach Microsoft's servers, and falls back to + Microsoft's own installer when that host is unreachable. ARM64 computers are + covered for the first time; the older bundled installer was Intel-only. + - **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 772de513e8..b6c20b91f6 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`. | 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. | Keep `false`; take upstream's other updater settings. `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 the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, 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 bundle 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; 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 standalone per-architecture MSI (`/codev/installers/wsl...msi`), 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. | | 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. | @@ -221,6 +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` (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`. | | `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. | | `packages/app/src/pages/layout/project-avatar-state.ts` | Both `ensureServerState` lookups are gated on `connection()`: `unread` before `notification.ensureServerState`, and the `permissionState` memo before `permission.ensureServerState` (which also moves below `connection`, and its consumer in `hasPermissions` handles the undefined state). Matches the guard `hasPermissions`/`hasQuestions`/`loading` already apply to `sync()`. | `ensureServerState` throws on a key absent from the connection list, and the throw propagates out of a render-time memo, killing the whole renderer. Sidecar connections are listed only while healthy — `readySandboxConnections`/`readyWslConnections` publish a sandbox or WSL server only in the `ready` state — so any removal, restart, or crash of one drops its key while its session tabs are still mounted. Reproduced by removing a sandbox that had an open session tab: `Error: Notification server not found: sandbox:`. The permission lookup is the sharper of the two because `createMemo` computes eagerly: for a tab mounted after its server disappeared it throws during construction, before any downstream guard runs — `Error: Permission server not found: sandbox:`, reproduced by deleting a sandbox from Settings while the Work side was open. | Keep the guard; take upstream's other changes to the hook. This is an upstream bug (WSL servers hit the identical path), so if upstream fixes it their way, drop ours in favour of theirs. | diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 92baaac04a..eac16b9a64 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -393,6 +393,15 @@ export const dict = { "sandbox.addDefault.intro": "Work runs your sessions in a sandbox: an isolated environment on this computer. Setting one up downloads its image, which takes a few minutes. You choose which of your folders it can reach once it is ready.", "sandbox.addDefault.create": "Set up", + "sandbox.wsl.installAction": "Install WSL", + "sandbox.wsl.installRetry": "Try again", + "sandbox.wsl.installHint": + "CoDev can install WSL for you. Windows will ask for permission, and the download is around 250 MB.", + "sandbox.wsl.downloading": "Downloading WSL... {{done}} MB of {{total}} MB", + "sandbox.wsl.downloadingUnknownSize": "Downloading WSL... {{done}} 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.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", "sandbox.server.add": "Add sandbox", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index dcf53b2b9e..f00776bb6b 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -384,6 +384,14 @@ export const dict = { "sandbox.addDefault.intro": "Trabajo ejecuta tus sesiones en un sandbox: un entorno aislado en este equipo. Configurarlo descarga su imagen, lo que tarda unos minutos. Tú eliges a qué carpetas tuyas puede acceder cuando esté listo.", "sandbox.addDefault.create": "Configurar", + "sandbox.wsl.installAction": "Instalar WSL", + "sandbox.wsl.installRetry": "Reintentar", + "sandbox.wsl.installHint": "CoDev puede instalar WSL por ti. Windows pedirá permiso y la descarga ocupa unos 250 MB.", + "sandbox.wsl.downloading": "Descargando WSL... {{done}} MB de {{total}} MB", + "sandbox.wsl.downloadingUnknownSize": "Descargando WSL... {{done}} 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.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", "sandbox.server.add": "Añadir sandbox", diff --git a/packages/app/src/sandbox/context.tsx b/packages/app/src/sandbox/context.tsx index dd296c3c77..f4ce4e566e 100644 --- a/packages/app/src/sandbox/context.tsx +++ b/packages/app/src/sandbox/context.tsx @@ -1,11 +1,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query" import { createEffect, onCleanup } from "solid-js" -import type { SandboxAvailability, SandboxesState } from "./types" +import type { SandboxAvailability, SandboxesState, WslInstallState } from "./types" import { usePlatform } from "../context/platform" const sandboxesQueryKey = ["platform", "sandboxes"] as const const sandboxAvailabilityQueryKey = ["platform", "sandboxAvailability"] as const +const wslInstallQueryKey = ["platform", "sandboxWslInstall"] as const export const { use: useSandboxes, provider: SandboxesProvider } = createSimpleContext({ name: "Sandboxes", @@ -32,6 +33,16 @@ export const { use: useSandboxes, provider: SandboxesProvider } = createSimpleCo gcTime: Number.POSITIVE_INFINITY, }) }) + const wslInstall = useQuery(() => { + const api = platform.sandboxes + return queryOptions({ + queryKey: wslInstallQueryKey, + queryFn: () => api!.wslInstallState(), + enabled: !!api, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + }) + }) createEffect(() => { const api = platform.sandboxes @@ -42,9 +53,25 @@ export const { use: useSandboxes, provider: SandboxesProvider } = createSimpleCo onCleanup(off) }) + createEffect(() => { + const api = platform.sandboxes + if (!api) return + const off = api.subscribeWslInstall((state) => { + queryClient.setQueryData(wslInstallQueryKey, state) + }) + onCleanup(off) + }) + return { state: state as typeof state & { readonly data: SandboxesState | undefined }, availability: availability as typeof availability & { readonly data: SandboxAvailability | undefined }, + wslInstall: wslInstall as typeof wslInstall & { readonly data: WslInstallState | undefined }, + // Availability is answered once per app run and cached forever, which is + // right for a verdict that only a Windows install can change — and wrong + // the moment this app performs that install. Anything that makes WSL + // appear has to say so here, or the dialog goes on reporting the machine + // it found at launch. + refreshAvailability: () => queryClient.invalidateQueries({ queryKey: sandboxAvailabilityQueryKey }), } }, }) diff --git a/packages/app/src/sandbox/dialog-add-default.tsx b/packages/app/src/sandbox/dialog-add-default.tsx index 247f0296a1..27c9018bcd 100644 --- a/packages/app/src/sandbox/dialog-add-default.tsx +++ b/packages/app/src/sandbox/dialog-add-default.tsx @@ -12,6 +12,7 @@ import type { SandboxConfig } from "./types" import { useSandboxes } from "./context" import { sandboxCreatePhase } from "./create-phase" import { createSandboxImageField } from "./image-field" +import { SandboxWslInstall } from "./wsl-install" // The sandbox Work offers when there is none: everything DialogAddSandbox asks // for has an answer the app can give, so it asks for none of it. The name, the @@ -98,7 +99,7 @@ export function DialogAddDefaultSandbox(props: { onCreated?: (config: SandboxCon {unsupportedMessage()}

} + fallback={} > {/* warn-never-block: the warning is shown, creation stays enabled */} {(text) =>

{text()}

}
diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx index a074276cb6..703fcc8afb 100644 --- a/packages/app/src/sandbox/dialog-add.tsx +++ b/packages/app/src/sandbox/dialog-add.tsx @@ -21,6 +21,7 @@ import { useSandboxes } from "./context" import { sandboxCreatePhase } from "./create-phase" import { createSandboxImageField } from "./image-field" import { sandboxNameUsable, validateSandboxName } from "./name-model" +import { SandboxWslInstall } from "./wsl-install" import { SANDBOX_CPUS_RANGE, SANDBOX_DEFAULT_CPUS, @@ -167,7 +168,7 @@ export function DialogAddSandbox(props: { {unsupportedMessage()}

} + fallback={} > {/* warn-never-block: the warning is shown, creation stays enabled */} {(text) =>

{text()}

}
diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts index 392272d318..f37b5c3391 100644 --- a/packages/app/src/sandbox/types.ts +++ b/packages/app/src/sandbox/types.ts @@ -91,10 +91,33 @@ export type SandboxImageProblem = export type SandboxAvailability = { supported: true; warning: string | null } | { supported: false; message: string } +// Installing the WSL runtime from inside a create-sandbox dialog, Windows +// only. The main process owns this state so closing the dialog does not lose +// track of a download that is still running. +export type WslInstallState = + | { status: "idle" } + // totalBytes is 0 when the server sent no content-length: size unknown, not + // a finished download. + | { status: "downloading"; bytes: number; totalBytes: number } + | { status: "installing" } + // Installed, but WSL cannot answer until Windows restarts — enabling + // VirtualMachinePlatform on a machine that never had it needs a reboot. + | { status: "restart-required" } + | { status: "installed" } + | { status: "failed"; message: string } + export type SandboxesPlatform = { getState(): Promise subscribe(cb: (event: SandboxesEvent) => void): () => void availability(): Promise + // Installs the WSL runtime so a Windows machine without it can create a + // sandbox without leaving the dialog. Resolves when the attempt is over, + // however it went; the outcome is in the state, since the interesting ones + // (needs a restart, failed) are not exceptions. + installWsl(): Promise + cancelWslInstall(): Promise + wslInstallState(): Promise + subscribeWslInstall(cb: (state: WslInstallState) => void): () => void // Resolves undefined when the create was cancelled mid-download. add( name: string, diff --git a/packages/app/src/sandbox/wsl-install.tsx b/packages/app/src/sandbox/wsl-install.tsx new file mode 100644 index 0000000000..6d022f55eb --- /dev/null +++ b/packages/app/src/sandbox/wsl-install.tsx @@ -0,0 +1,133 @@ +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 { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useSandboxes } from "./context" + +// What both create-sandbox dialogs show in place of their form when the +// machine cannot run a sandbox. On Windows that is nearly always one missing +// thing — the WSL runtime — which the app can install itself, so this offers +// to rather than sending the user away to do it by hand. Everywhere else it +// stays the plain message it has always been: a macOS too old for +// Virtualization.framework is not something a download fixes. +// +// Shared by DialogAddSandbox and DialogAddDefaultSandbox because the state and +// the wording are identical; only the dialog around them differs. +export function SandboxWslInstall(props: { message: string }) { + const language = useLanguage() + const platform = usePlatform() + const sandboxes = useSandboxes() + const api = platform.sandboxes + + const state = () => sandboxes.wslInstall.data ?? { status: "idle" as const } + const downloading = () => { + const current = state() + return current.status === "downloading" ? current : undefined + } + const failure = () => { + const current = state() + return current.status === "failed" ? current.message : undefined + } + const busy = () => state().status === "downloading" || state().status === "installing" + // Only Windows has an installer to offer. The web build has no sandbox api + // at all, so it never reaches the button. + const offered = () => !!api && platform.platform === "desktop" && platform.os === "windows" + + // The verdict the dialogs gate on is answered once per app run and cached + // forever, so a finished install has to invalidate it or the form never + // appears and the user is told to install what they just installed. + createEffect(() => { + if (state().status !== "installed") return + void sandboxes.refreshAvailability() + }) + + const install = () => { + if (!api || busy()) return + api.installWsl().catch((error: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + }) + } + + return ( +
+

{props.message}

+ + + + {(current) => ( +
+
+
+
+ {/* A server that sent no content-length leaves the total at 0; + say what is happening rather than quote "of 0 MB". */} +

+ 0} + fallback={language.t("sandbox.wsl.downloadingUnknownSize", { + done: Math.round(current().bytes / 1024 / 1024), + })} + > + {language.t("sandbox.wsl.downloading", { + done: Math.round(current().bytes / 1024 / 1024), + total: Math.round(current().totalBytes / 1024 / 1024), + })} + +

+
+ )} + + {/* msiexec says nothing until it exits, and the elevation prompt is + the user's next move, so name it rather than show a bar that + cannot move. */} + +

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

+
+ {/* Terminal: nothing this app can do next, and running the installer + again would not change it. */} + +

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

+
+ + {(message) => ( +

{language.t("sandbox.wsl.failed", { message: message() })}

+ )} +
+ + +
+ + + + + + {/* Only the download is worth stopping; the elevated step belongs + to msiexec and Windows offers nothing to cancel. */} + + void api?.cancelWslInstall()}> + {language.t("common.cancel")} + + +
+ +

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

+
+
+ +
+ ) +} diff --git a/packages/codev-sandbox/src/const.ts b/packages/codev-sandbox/src/const.ts index eeb531d9bd..79f7049c3c 100644 --- a/packages/codev-sandbox/src/const.ts +++ b/packages/codev-sandbox/src/const.ts @@ -29,5 +29,36 @@ export const INSTALLERS_BASE_URL = `${STORAGE_BASE}/installers` // distro image for the WSL servers feature (`wsl --install --from-file`), // not the sandbox rootfs, which ships via the image manifest instead. export const WSL_INSTALLER_ZIP_URL = `${INSTALLERS_BASE_URL}/wsl.zip` -export const WSL_INSTALLER_MSI_NAME = "wsl.2.7.11.0.x64.msi" export const WSL_DISTRO_IMAGE_NAME = "ubuntu-24.04.4-wsl-amd64.wsl" + +// The WSL runtime installers on their own, one per architecture. Published +// under the same `/codev` prefix as the sandbox images rather than beside +// wsl.zip: that zip is already uploaded at the older path, and moving it would +// break the distro install for no gain. +// +// Bare MSIs rather than a second zip because the sandbox needs only the +// runtime — it brings its own rootfs and its own server — so there is nothing +// to bundle alongside and nothing to extract. It is also what makes ARM64 +// reachable at all: the zip is x64-only. +export const WSL_INSTALLER_MSI_BASE_URL = `${STORAGE_BASE}/codev/installers` + +// Version-pinned, and the x64 entry is deliberately the same file wsl.zip +// carries — same version, same name — so the two acquisition paths share one +// cache slot instead of fetching the same installer twice. 2.7.11 is the +// newest stable WSL release; it supports Windows 10 (build 19041+) and +// Windows 11 alike, which is what keeps one pin good for the whole fleet. +export const WSL_INSTALLER_MSI_NAMES = { + x64: "wsl.2.7.11.0.x64.msi", + arm64: "wsl.2.7.11.0.arm64.msi", +} as const + +export type WslInstallerArch = keyof typeof WSL_INSTALLER_MSI_NAMES + +// The zip's member name. Derived rather than repeated: if the pin moves, the +// bundle and the standalone download must move together or an x64 machine +// downloads two different versions of the same installer. +export const WSL_INSTALLER_MSI_NAME = WSL_INSTALLER_MSI_NAMES.x64 + +export function wslInstallerMsiName(arch: string) { + return arch === "x64" || arch === "arm64" ? WSL_INSTALLER_MSI_NAMES[arch] : undefined +} diff --git a/packages/desktop/src/main/sandbox/ipc.ts b/packages/desktop/src/main/sandbox/ipc.ts index 73ea3370a0..b328bac35f 100644 --- a/packages/desktop/src/main/sandbox/ipc.ts +++ b/packages/desktop/src/main/sandbox/ipc.ts @@ -11,6 +11,8 @@ 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 { write } from "../logging" // Unlike the WSL channels there is no unavailable-stub variant: the controller // exists on every platform, and availability() carries the unsupported message @@ -22,6 +24,16 @@ export function registerSandboxIpcHandlers(controller: DesktopSandboxController) userData: app.getPath("userData"), appName: app.getName(), }) + // Availability is what the installer reports against: a finished install + // that still needs a reboot leaves WSL unusable, and this is the same probe + // the dialogs gate on. + const wslInstall = createWslRuntimeInstaller({ + availability: () => controller.availability(), + logger: { + log: (message, meta) => write("sandbox", message, meta as Record), + error: (message, meta) => write("sandbox", message, meta as Record, "error"), + }, + }) const subscriptions = new Map void>() const unsubscribe = (id: number) => { const off = subscriptions.get(id) @@ -29,10 +41,19 @@ export function registerSandboxIpcHandlers(controller: DesktopSandboxController) off() subscriptions.delete(id) } + const wslInstallSubscriptions = new Map void>() + const unsubscribeWslInstall = (id: number) => { + const off = wslInstallSubscriptions.get(id) + if (!off) return + off() + wslInstallSubscriptions.delete(id) + } app.once("will-quit", () => { subscriptions.forEach((off) => off()) subscriptions.clear() + wslInstallSubscriptions.forEach((off) => off()) + wslInstallSubscriptions.clear() }) ipcMain.handle("sandbox-subscribe", (event) => { @@ -53,6 +74,28 @@ export function registerSandboxIpcHandlers(controller: DesktopSandboxController) ipcMain.handle("sandbox-unsubscribe", (event) => unsubscribe(event.sender.id)) ipcMain.handle("sandbox-get-state", () => controller.getState()) ipcMain.handle("sandbox-availability", () => controller.availability()) + // Its own channel set rather than a second event type on sandbox-event: this + // is platform state, not sandbox state, and it exists before any sandbox + // does. + ipcMain.handle("sandbox-wsl-install-subscribe", (event) => { + const id = event.sender.id + if (wslInstallSubscriptions.has(id)) return + wslInstallSubscriptions.set( + id, + wslInstall.subscribe((state) => { + if (event.sender.isDestroyed()) { + unsubscribeWslInstall(id) + return + } + event.sender.send("sandbox-wsl-install-event", state) + }), + ) + event.sender.once("destroyed", () => unsubscribeWslInstall(id)) + }) + ipcMain.handle("sandbox-wsl-install-unsubscribe", (event) => unsubscribeWslInstall(event.sender.id)) + ipcMain.handle("sandbox-wsl-install-state", () => wslInstall.getState()) + ipcMain.handle("sandbox-wsl-install", () => wslInstall.install()) + ipcMain.handle("sandbox-wsl-install-cancel", () => wslInstall.cancel()) // 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 new file mode 100644 index 0000000000..9f13e4f4be --- /dev/null +++ b/packages/desktop/src/main/sandbox/wsl-install.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from "bun:test" +import type { SandboxAvailability } from "@codev/sandbox" +import type { WslInstallState } from "./wsl-install" +import { createWslRuntimeInstaller } from "./wsl-install" + +const supported: SandboxAvailability = { supported: true, warning: null } +const unsupported: SandboxAvailability = { supported: false, message: "WSL 2 is not installed." } +const ok = { code: 0, signal: null, stdout: "", stderr: "" } + +function record(installer: ReturnType) { + const seen: WslInstallState[] = [] + installer.subscribe((state) => seen.push(state)) + return { seen, statuses: () => seen.map((state) => state.status) } +} + +test("reports the download, then the elevated install, then success", async () => { + const installer = createWslRuntimeInstaller({ + platform: "win32", + availability: async () => supported, + install: async (opts) => { + opts?.onProgress?.({ phase: "downloading", bytes: 100, totalBytes: 400 }) + opts?.onProgress?.({ phase: "installing" }) + return ok + }, + }) + const events = record(installer) + + await installer.install() + + expect(events.statuses()).toEqual(["downloading", "downloading", "installing", "installed"]) + expect(events.seen[1]).toEqual({ status: "downloading", bytes: 100, totalBytes: 400 }) + 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. +test("a clean install that leaves WSL unusable reports restart-required", async () => { + const installer = createWslRuntimeInstaller({ + platform: "win32", + availability: async () => unsupported, + install: async () => ok, + }) + + await installer.install() + + expect(installer.getState()).toEqual({ status: "restart-required" }) +}) + +test("a failed install surfaces the installer's own output", async () => { + const installer = createWslRuntimeInstaller({ + platform: "win32", + availability: async () => supported, + install: async () => ({ code: 1603, signal: null, stdout: "", stderr: " fatal error during installation\n" }), + }) + + await installer.install() + + expect(installer.getState()).toEqual({ status: "failed", message: "fatal error during installation" }) +}) + +test("an installer that exits non-zero silently still names the exit code", async () => { + const installer = createWslRuntimeInstaller({ + platform: "win32", + availability: async () => supported, + install: async () => ({ code: 1603, signal: null, stdout: "", stderr: "" }), + }) + + await installer.install() + + expect(installer.getState()).toEqual({ status: "failed", message: "WSL installation failed (exit 1603)" }) +}) + +test("an acquisition error is reported as a failure", async () => { + const installer = createWslRuntimeInstaller({ + platform: "win32", + availability: async () => supported, + install: async () => { + throw new Error("downloading wsl.msi failed: HTTP 404") + }, + }) + + await installer.install() + + expect(installer.getState()).toEqual({ status: "failed", message: "downloading wsl.msi failed: HTTP 404" }) +}) + +// Cancelling is the user walking away from a download, not something to report +// back to them as an error. +test("cancelling returns to idle rather than failing", async () => { + const installer = createWslRuntimeInstaller({ + platform: "win32", + availability: async () => supported, + install: async (opts) => { + installer.cancel() + opts?.signal?.throwIfAborted() + return ok + }, + }) + + await installer.install() + + expect(installer.getState()).toEqual({ status: "idle" }) +}) + +// Both create-sandbox dialogs reach the same installer, and two elevated +// installs of the same MSI would stack two UAC prompts. +test("a second install while one is running is a no-op", async () => { + let starts = 0 + let release = () => {} + const held = new Promise((resolve) => { + release = resolve + }) + const installer = createWslRuntimeInstaller({ + platform: "win32", + availability: async () => supported, + install: async () => { + starts += 1 + await held + return ok + }, + }) + + const first = installer.install() + await installer.install() + expect(starts).toBe(1) + expect(installer.getState().status).toBe("downloading") + + release() + await first + expect(starts).toBe(1) +}) + +test("refuses to run anywhere but Windows", async () => { + const installer = createWslRuntimeInstaller({ platform: "darwin", availability: async () => supported }) + + expect(installer.install()).rejects.toThrow("Windows") +}) diff --git a/packages/desktop/src/main/sandbox/wsl-install.ts b/packages/desktop/src/main/sandbox/wsl-install.ts new file mode 100644 index 0000000000..00f9e28b0c --- /dev/null +++ b/packages/desktop/src/main/sandbox/wsl-install.ts @@ -0,0 +1,117 @@ +import type { SandboxAvailability } from "@codev/sandbox" +import { installWslRuntimeDualPath } from "../wsl/install-bundle" +import { 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, +// Microsoft's installer as the fallback); what is different here is that a +// sandbox needs only the runtime — it imports its own rootfs and mounts its +// own server — so there is no distro step, and that the create-sandbox dialogs +// need something to render while it happens. +// +// State lives in the main process rather than in the dialog: the download is +// hundreds of megabytes on a link measured in tens of KB/s, and closing the +// dialog must not lose track of an install that is still running. +export type WslInstallState = + | { status: "idle" } + // totalBytes is 0 when the server sent no content-length; the UI reads that + // as "size unknown" rather than as a finished download. + | { status: "downloading"; bytes: number; totalBytes: number } + | { status: "installing" } + | { status: "restart-required" } + | { status: "installed" } + | { status: "failed"; message: string } + +type InstallLogger = { + log: (message: string, meta?: unknown) => void + error: (message: string, meta?: unknown) => void +} + +export type WslRuntimeInstaller = ReturnType + +// Takes its platform and its installer rather than reading them, so the +// Windows-only path — the one with a reason to exist — is checkable from a +// machine that is not Windows (same reasoning as workspace.ts). +export function createWslRuntimeInstaller(deps: { + availability: () => Promise + // Taken rather than imported: main/logging.ts reaches for Electron's + // crashReporter at module load, which no test runner can import. + logger?: InstallLogger + platform?: NodeJS.Platform + install?: typeof installWslRuntimeDualPath +}) { + let state: WslInstallState = { status: "idle" } + let abort: AbortController | undefined + const listeners = new Set<(state: WslInstallState) => void>() + const logger = deps.logger ?? { log: () => {}, error: () => {} } + + const setState = (next: WslInstallState) => { + state = next + for (const listener of listeners) listener(state) + } + + return { + getState() { + return state + }, + subscribe(listener: (state: WslInstallState) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + + // Stops the download. The elevated step is msiexec's to finish — Windows + // gives nothing to cancel there — so this only bites while bytes are + // moving, which is the part worth being able to walk away from. + cancel() { + abort?.abort() + }, + + async install() { + if ((deps.platform ?? process.platform) !== "win32") throw new Error("WSL can only be installed on Windows") + // Single-flight: both create-sandbox dialogs can reach this, and two + // elevated installs of the same MSI would stack two UAC prompts. + if (state.status === "downloading" || state.status === "installing") return + abort = new AbortController() + setState({ status: "downloading", bytes: 0, totalBytes: 0 }) + logger.log("installing the WSL runtime for the sandbox") + const result = await (deps.install ?? installWslRuntimeDualPath)( + { + signal: abort.signal, + onProgress: (progress) => + setState( + progress.phase === "installing" + ? { status: "installing" } + : { status: "downloading", bytes: progress.bytes, totalBytes: progress.totalBytes }, + ), + }, + logger, + ).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error)))) + + if (result instanceof Error) { + // A cancelled download is not a failure to report; it puts the dialog + // back where it was, with the Install button live again. + if (abort.signal.aborted) { + setState({ status: "idle" }) + return + } + logger.error("WSL install failed", { message: result.message }) + setState({ status: "failed", message: result.message }) + 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. + 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 4e41d55e32..dfca9aa387 100644 --- a/packages/desktop/src/main/wsl/install-bundle.test.ts +++ b/packages/desktop/src/main/wsl/install-bundle.test.ts @@ -2,8 +2,19 @@ import { expect, test } from "bun:test" import { mkdtemp, readFile, readdir } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { WSL_DISTRO_IMAGE_NAME, WSL_INSTALLER_MSI_NAME } from "@codev/sandbox" -import { bundleProvidesDistro, ensureWslInstallBundle, wslRuntimeInstallScript } from "./install-bundle" +import { + WSL_DISTRO_IMAGE_NAME, + WSL_INSTALLER_MSI_BASE_URL, + WSL_INSTALLER_MSI_NAME, + WSL_INSTALLER_MSI_NAMES, +} from "@codev/sandbox" +import type { WslInstallProgress } from "./install-bundle" +import { + bundleProvidesDistro, + ensureWslInstallBundle, + ensureWslRuntimeMsi, + wslRuntimeInstallScript, +} from "./install-bundle" // Built with zip.js rather than checked in as a fixture: the test only cares // that members are found and extracted by exact name, not about zip bytes. @@ -63,3 +74,84 @@ test("only Ubuntu-24.04 is served from the bundle", () => { expect(bundleProvidesDistro("Ubuntu-22.04")).toBe(false) expect(bundleProvidesDistro("Debian")).toBe(false) }) + +test("downloads the MSI for this machine's architecture and caches it", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const asked: string[] = [] + const fetchImpl = (async (url: string) => { + asked.push(url) + return new Response("msi-bytes", { headers: { "content-length": "9" } }) + }) as unknown as typeof fetch + + const first = await ensureWslRuntimeMsi({ dir, arch: "arm64", fetchImpl }) + expect(first).toBe(join(dir, WSL_INSTALLER_MSI_NAMES.arm64)) + expect(await readFile(first, "utf8")).toBe("msi-bytes") + expect(asked).toEqual([`${WSL_INSTALLER_MSI_BASE_URL}/${WSL_INSTALLER_MSI_NAMES.arm64}`]) + // Nothing half-written survives a completed download. + expect(await readdir(dir)).toEqual([WSL_INSTALLER_MSI_NAMES.arm64]) + + expect(await ensureWslRuntimeMsi({ dir, arch: "arm64", fetchImpl })).toBe(first) + expect(asked.length).toBe(1) +}) + +// The x64 MSI is the same file wsl.zip carries, under the same name, so a +// machine that already pulled the bundle for a WSL server must not download +// the installer a second time. +test("the x64 MSI shares its cache slot with the bundle's copy", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const zip = await fixtureZip() + const fetchImpl = (async () => new Response(zip)) as typeof fetch + await ensureWslInstallBundle({ dir, fetchImpl }) + + const failIfFetched = (async () => { + throw new Error("should not download") + }) as unknown as typeof fetch + expect(await ensureWslRuntimeMsi({ dir, arch: "x64", fetchImpl: failIfFetched })).toBe( + join(dir, WSL_INSTALLER_MSI_NAME), + ) +}) + +test("reports download progress against the advertised size", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(4)) + controller.enqueue(new Uint8Array(6)) + controller.close() + }, + }) + const fetchImpl = (async () => new Response(body, { headers: { "content-length": "10" } })) as unknown as typeof fetch + const seen: WslInstallProgress[] = [] + + await ensureWslRuntimeMsi({ dir, arch: "x64", fetchImpl, onProgress: (progress) => seen.push(progress) }) + + // Reporting is interval-based, so only the flush at the end is guaranteed — + // and it must land on the full size rather than stall short of it. + expect(seen.at(-1)).toEqual({ phase: "downloading", bytes: 10, totalBytes: 10 }) +}) + +// A server that sends no content-length must not leave the UI dividing by zero +// forever; the final report falls back to what actually arrived. +test("a sizeless response still finishes at 100%", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const fetchImpl = (async () => new Response("msi-bytes")) as unknown as typeof fetch + const seen: WslInstallProgress[] = [] + + await ensureWslRuntimeMsi({ dir, arch: "x64", fetchImpl, onProgress: (progress) => seen.push(progress) }) + + expect(seen.at(-1)).toEqual({ phase: "downloading", bytes: 9, totalBytes: 9 }) +}) + +// Falling through to Microsoft's installer is the right answer for an +// architecture we publish nothing for, so this has to throw rather than +// resolve to a path that does not exist. +test("an unpublished architecture throws so the caller falls back", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + expect(ensureWslRuntimeMsi({ dir, arch: "ia32" })).rejects.toThrow("ia32") +}) + +test("a missing MSI on the storage host throws with its status", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const fetchImpl = (async () => new Response("nope", { status: 404 })) as unknown as typeof fetch + expect(ensureWslRuntimeMsi({ dir, arch: "x64", fetchImpl })).rejects.toThrow("404") +}) diff --git a/packages/desktop/src/main/wsl/install-bundle.ts b/packages/desktop/src/main/wsl/install-bundle.ts index 6475035822..f76c3aae12 100644 --- a/packages/desktop/src/main/wsl/install-bundle.ts +++ b/packages/desktop/src/main/wsl/install-bundle.ts @@ -3,7 +3,13 @@ import { mkdir, rename, rm, writeFile } from "node:fs/promises" import { dirname, join } from "node:path" import { Writable } from "node:stream" import electron from "electron" -import { WSL_DISTRO_IMAGE_NAME, WSL_INSTALLER_MSI_NAME, WSL_INSTALLER_ZIP_URL } from "@codev/sandbox" +import { + WSL_DISTRO_IMAGE_NAME, + WSL_INSTALLER_MSI_BASE_URL, + WSL_INSTALLER_MSI_NAME, + WSL_INSTALLER_ZIP_URL, + wslInstallerMsiName, +} from "@codev/sandbox" import type { RunWslOptions, WslCommandResult } from "./runtime" import { installWslDistro, @@ -14,11 +20,19 @@ import { } from "./runtime" // The customer fleet sits behind whitelist-only proxies that cannot reach -// Microsoft's servers, so WSL setup prefers the hand-uploaded bundle on the -// codev storage host and only falls back to the original `wsl --install` -// paths when the bundle cannot be obtained. Genuine install failures are NOT -// retried on the fallback: both paths need the same elevation, and a second -// UAC prompt right after a failed one helps nobody. +// Microsoft's servers, so WSL setup prefers what is hand-uploaded to the codev +// storage host and only falls back to the original `wsl --install` paths when +// that cannot be obtained. Genuine install failures are NOT retried on the +// fallback: both paths need the same elevation, and a second UAC prompt right +// after a failed one helps nobody. +// +// Two things are published there, for two callers. The runtime is a standalone +// per-architecture MSI — the sandbox needs only that, since it imports its own +// rootfs and mounts its own server — and it is what makes ARM64 reachable at +// all. The distro image for WSL *servers* still travels in the x64-only +// wsl.zip alongside a copy of the same x64 MSI, which is why the two +// acquisition paths deliberately share one cache directory and one filename: +// whichever ran first, the other finds the installer already there. // What the distro image registers itself as (its embedded // /etc/wsl-distribution.conf shortname), and also the name Microsoft's online @@ -35,22 +49,34 @@ type InstallLogger = { error: (message: string, meta?: unknown) => void } +// Which of the two long steps an install is on. The elevated step reports no +// bytes — msiexec is silent until it exits — so a caller showing progress has +// to switch from a bar to a spinner here rather than stall at 100%. +export type WslInstallProgress = { phase: "downloading"; bytes: number; totalBytes: number } | { phase: "installing" } + export type InstallBundleOptions = RunWslOptions & { dir?: string url?: string + // Overrides the standalone runtime MSI's URL. Separate from `url`, which + // addresses the distro bundle's zip; a caller can be pointed at a fixture + // for one without redirecting the other. + msiUrl?: string + arch?: string fetchImpl?: typeof fetch + onProgress?: (progress: WslInstallProgress) => void } export async function installWslRuntimeDualPath( opts?: InstallBundleOptions, logger?: InstallLogger, ): Promise { - const primary = bundleSupportsThisMachine() - ? await installWslRuntimeFromBundle(opts).catch(intoError) - : new Error("the WSL install bundle is x64-only") + const primary = await installWslRuntimeFromMsi(opts).catch(intoError) if (!(primary instanceof Error)) return primary if (opts?.signal?.aborted) throw primary logger?.error("bundled WSL install unavailable; falling back to wsl --install", { message: primary.message }) + // The fallback downloads through Microsoft's own installer, which reports + // nothing back here; from the caller's side it is one opaque step. + opts?.onProgress?.({ phase: "installing" }) return installWslRuntimeElevated(opts) } @@ -80,13 +106,13 @@ export function bundleProvidesDistro(name: string) { return name.toLowerCase() === BUNDLED_DISTRO_NAME.toLowerCase() } -// Acquisition problems (download, extraction) throw and let the caller fall -// back; the elevated install itself ran once we return a result, even a -// failing one. -export async function installWslRuntimeFromBundle(opts?: InstallBundleOptions): Promise { - const bundle = await ensureWslInstallBundle(opts) - const scriptPath = join(dirname(bundle.msiPath), "install-wsl-runtime.ps1") - await writeFile(scriptPath, wslRuntimeInstallScript(bundle.msiPath)) +// Acquisition problems (download) throw and let the caller fall back; the +// elevated install itself ran once we return a result, even a failing one. +export async function installWslRuntimeFromMsi(opts?: InstallBundleOptions): Promise { + const msiPath = await ensureWslRuntimeMsi(opts) + opts?.onProgress?.({ phase: "installing" }) + const scriptPath = join(dirname(msiPath), "install-wsl-runtime.ps1") + await writeFile(scriptPath, wslRuntimeInstallScript(msiPath)) const command = [ "$ErrorActionPreference = 'Stop'", `$process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',('"{0}"' -f ${psQuote(scriptPath)})) -Wait -PassThru`, @@ -111,6 +137,25 @@ export function wslRuntimeInstallScript(msiPath: string) { ].join("\r\n") } +// The runtime installer for this machine's architecture, downloaded on its +// own. Cached in the same directory wsl.zip extracts into and under the same +// filename, so an x64 machine that already pulled the bundle for a WSL server +// finds the MSI here and downloads nothing. +export async function ensureWslRuntimeMsi(opts?: InstallBundleOptions) { + const arch = opts?.arch ?? process.arch + const name = wslInstallerMsiName(arch) + // Throws rather than returning nothing so the caller falls back to + // Microsoft's installer, which does publish for every architecture Windows + // runs on. + if (!name) throw new Error(`no WSL installer is published for ${arch}`) + const dir = opts?.dir ?? join(electron.app.getPath("userData"), "wsl-installer") + const msiPath = join(dir, name) + if (existsSync(msiPath)) return msiPath + await mkdir(dir, { recursive: true }) + await downloadTo(msiPath, opts?.msiUrl ?? `${WSL_INSTALLER_MSI_BASE_URL}/${name}`, opts) + return msiPath +} + export async function ensureWslInstallBundle(opts?: InstallBundleOptions) { const dir = opts?.dir ?? join(electron.app.getPath("userData"), "wsl-installer") const msiPath = join(dir, WSL_INSTALLER_MSI_NAME) @@ -119,7 +164,7 @@ export async function ensureWslInstallBundle(opts?: InstallBundleOptions) { await mkdir(dir, { recursive: true }) const zipPath = join(dir, "wsl.zip") - if (!existsSync(zipPath)) await downloadTo(zipPath, opts) + if (!existsSync(zipPath)) await downloadTo(zipPath, opts?.url ?? WSL_INSTALLER_ZIP_URL, opts) await extractZipMembers(zipPath, dir, [WSL_INSTALLER_MSI_NAME, WSL_DISTRO_IMAGE_NAME]) if (!existsSync(msiPath) || !existsSync(distroImagePath)) throw new Error(`wsl.zip did not contain ${WSL_INSTALLER_MSI_NAME} and ${WSL_DISTRO_IMAGE_NAME}`) @@ -128,9 +173,8 @@ export async function ensureWslInstallBundle(opts?: InstallBundleOptions) { return { msiPath, distroImagePath } } -async function downloadTo(zipPath: string, opts?: InstallBundleOptions) { +async function downloadTo(destination: string, url: string, opts?: InstallBundleOptions) { const fetchImpl = opts?.fetchImpl ?? fetch - const url = opts?.url ?? WSL_INSTALLER_ZIP_URL const signal = AbortSignal.any( [opts?.signal, AbortSignal.timeout(opts?.timeoutMs ?? DOWNLOAD_TIMEOUT_MS)].filter( (value): value is AbortSignal => value !== undefined, @@ -138,9 +182,36 @@ async function downloadTo(zipPath: string, opts?: InstallBundleOptions) { ) const response = await fetchImpl(url, { signal }) if (!response.ok || !response.body) throw new Error(`downloading ${url} failed: HTTP ${response.status}`) - const partial = `${zipPath}.partial` - await response.body.pipeTo(Writable.toWeb(createWriteStream(partial)), { signal }) - await rename(partial, zipPath) + const partial = `${destination}.partial` + const onProgress = opts?.onProgress + // A missing content-length leaves totalBytes at 0; the UI reads that as + // "size unknown" and shows an indeterminate state rather than dividing by it. + const totalBytes = Number(response.headers.get("content-length") ?? 0) + const body = onProgress ? response.body.pipeThrough(countingStream(totalBytes, onProgress)) : response.body + await body.pipeTo(Writable.toWeb(createWriteStream(partial)), { signal }) + await rename(partial, destination) +} + +// Progress crosses an IPC boundary, so it is reported on an interval rather +// than per chunk — a 250MB installer is tens of thousands of chunks. +const PROGRESS_INTERVAL_MS = 200 + +function countingStream(totalBytes: number, onProgress: (progress: WslInstallProgress) => void) { + let bytes = 0 + let lastReport = 0 + return new TransformStream({ + transform(chunk, controller) { + bytes += chunk.byteLength + if (Date.now() - lastReport >= PROGRESS_INTERVAL_MS) { + lastReport = Date.now() + onProgress({ phase: "downloading", bytes, totalBytes }) + } + controller.enqueue(chunk) + }, + flush() { + onProgress({ phase: "downloading", bytes, totalBytes: totalBytes || bytes }) + }, + }) } // zip.js instead of PowerShell Expand-Archive: streams each member to disk diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index bf14e398cb..031a9d63f8 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer, webUtils } from "electron" -import type { ElectronAPI, GatewaySsoState, SandboxesEvent, WslServersEvent } from "./types" +import type { ElectronAPI, GatewaySsoState, SandboxesEvent, WslInstallState, WslServersEvent } from "./types" import type { UpdaterState } from "@opencode-ai/app/updater" const updaterCallbacks = new Set<(state: UpdaterState) => void>() @@ -48,6 +48,18 @@ const api: ElectronAPI = { } }, availability: () => ipcRenderer.invoke("sandbox-availability"), + installWsl: () => ipcRenderer.invoke("sandbox-wsl-install"), + cancelWslInstall: () => ipcRenderer.invoke("sandbox-wsl-install-cancel"), + wslInstallState: () => ipcRenderer.invoke("sandbox-wsl-install-state"), + subscribeWslInstall: (cb) => { + const handler = (_: unknown, state: WslInstallState) => cb(state) + ipcRenderer.on("sandbox-wsl-install-event", handler) + void ipcRenderer.invoke("sandbox-wsl-install-subscribe") + return () => { + ipcRenderer.removeListener("sandbox-wsl-install-event", handler) + void ipcRenderer.invoke("sandbox-wsl-install-unsubscribe") + } + }, add: (name, projectDir, resources, imagePath) => ipcRenderer.invoke("sandbox-add", name, projectDir, resources, imagePath), addDefault: (imagePath) => ipcRenderer.invoke("sandbox-add-default", imagePath), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index ba51dbfa2d..122b9162be 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -24,6 +24,7 @@ export type { SandboxRuntime, SandboxesEvent, SandboxesState, + WslInstallState, } from "@opencode-ai/app/sandbox/types" export type { GatewayCompleteResult, GatewaySetupResult, GatewaySsoState } from "@opencode-ai/app/gateway/types" From e4e48ab0ad7089505ed7539acaba4a7218a462b3 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 18 Aug 2026 16:53:36 +0700 Subject: [PATCH 2/3] feat(app): stage the WSL installer through a resumable manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MSI was fetched by a URL built from a version pinned in code, straight to disk with no digest and no resume. A dropped connection at 90% of 250MB — on links observed at ~35 KB/s — started the whole download again, and a truncated file would have reached msiexec. The installers now ship a manifest of their own (/codev/installers/wsl/manifest.json, assets under /codev/installers/wsl//), and acquisition goes through stageAsset: the same resumable, content-addressed, sha256-verified downloader the guest images already use. Resume is Range-based and picks up where it stopped; a file that does not match its digest is rejected before it is installed. The manifest is standalone rather than the sandbox image manifest's existing wslInstaller slot, which is parsed but has no consumer: build-image.ts regenerates that whole document with wslInstaller null on every image publish, so an installer recorded there is erased the next time the guest image is rebuilt. Separate documents keep the two release cadences independent. The shape is shared — parseWslInstaller and its asset validation are reused, not copied — and the slot keeps a comment saying which one is live. The version pin moves out of the code and into the manifest, so bumping WSL is an upload rather than an app release. script/publish-wsl-installer.ts computes the digests, writes the manifest and uploads artifacts before the pointer, the way publish-image.ts does — hand-computed digests would fail verification only after a full download. wsl.zip is no longer asked for its x64 MSI member. Nothing reads it now that the runtime comes from the manifest for every architecture, so extracting it cost 250MB of disk for a file with no reader; the zip keeps serving the Ubuntu distro image for WSL servers. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 +- DIVERGENCES.md | 4 +- packages/app/src/i18n/en.ts | 1 - packages/app/src/i18n/es.ts | 1 - packages/app/src/sandbox/wsl-install.tsx | 23 +- .../script/publish-wsl-installer.ts | 83 ++++++ packages/codev-sandbox/src/const.ts | 40 ++- packages/codev-sandbox/src/manifest.test.ts | 44 ++- packages/codev-sandbox/src/manifest.ts | 41 +++ .../src/main/wsl/install-bundle.test.ts | 253 +++++++++++------- .../desktop/src/main/wsl/install-bundle.ts | 103 +++---- 11 files changed, 396 insertions(+), 204 deletions(-) create mode 100644 packages/codev-sandbox/script/publish-wsl-installer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa82cb950..2885e35d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,11 @@ file is the human-readable summary of what changed and why. the sandbox when it finishes — or says to restart Windows first when that is what WSL needs. The installer comes from the CoDev storage host, so it works on networks that cannot reach Microsoft's servers, and falls back to - Microsoft's own installer when that host is unreachable. ARM64 computers are - covered for the first time; the older bundled installer was Intel-only. + Microsoft's own installer when that host is unreachable. A download that is + cut off resumes where it stopped instead of starting the quarter-gigabyte + again, and an installer that arrives damaged is rejected rather than run. + ARM64 computers are covered for the first time; the older bundled installer + was Intel-only. - **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 diff --git a/DIVERGENCES.md b/DIVERGENCES.md index b6c20b91f6..4c5803d5ea 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`. | 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. | Keep `false`; take upstream's other updater settings. `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 standalone per-architecture MSI (`/codev/installers/wsl...msi`), 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; 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. | | 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. | @@ -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` (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, 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`. | | `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. | | `packages/app/src/pages/layout/project-avatar-state.ts` | Both `ensureServerState` lookups are gated on `connection()`: `unread` before `notification.ensureServerState`, and the `permissionState` memo before `permission.ensureServerState` (which also moves below `connection`, and its consumer in `hasPermissions` handles the undefined state). Matches the guard `hasPermissions`/`hasQuestions`/`loading` already apply to `sync()`. | `ensureServerState` throws on a key absent from the connection list, and the throw propagates out of a render-time memo, killing the whole renderer. Sidecar connections are listed only while healthy — `readySandboxConnections`/`readyWslConnections` publish a sandbox or WSL server only in the `ready` state — so any removal, restart, or crash of one drops its key while its session tabs are still mounted. Reproduced by removing a sandbox that had an open session tab: `Error: Notification server not found: sandbox:`. The permission lookup is the sharper of the two because `createMemo` computes eagerly: for a tab mounted after its server disappeared it throws during construction, before any downstream guard runs — `Error: Permission server not found: sandbox:`, reproduced by deleting a sandbox from Settings while the Work side was open. | Keep the guard; take upstream's other changes to the hook. This is an upstream bug (WSL servers hit the identical path), so if upstream fixes it their way, drop ours in favour of theirs. | diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index eac16b9a64..a984a2255b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -398,7 +398,6 @@ export const dict = { "sandbox.wsl.installHint": "CoDev can install WSL for you. Windows will ask for permission, and the download is around 250 MB.", "sandbox.wsl.downloading": "Downloading WSL... {{done}} MB of {{total}} MB", - "sandbox.wsl.downloadingUnknownSize": "Downloading WSL... {{done}} 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.failed": "WSL could not be installed: {{message}}", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index f00776bb6b..2f5ecf5f9f 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -388,7 +388,6 @@ export const dict = { "sandbox.wsl.installRetry": "Reintentar", "sandbox.wsl.installHint": "CoDev puede instalar WSL por ti. Windows pedirá permiso y la descarga ocupa unos 250 MB.", "sandbox.wsl.downloading": "Descargando WSL... {{done}} MB de {{total}} MB", - "sandbox.wsl.downloadingUnknownSize": "Descargando WSL... {{done}} 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.failed": "No se pudo instalar WSL: {{message}}", diff --git a/packages/app/src/sandbox/wsl-install.tsx b/packages/app/src/sandbox/wsl-install.tsx index 6d022f55eb..061b4fd135 100644 --- a/packages/app/src/sandbox/wsl-install.tsx +++ b/packages/app/src/sandbox/wsl-install.tsx @@ -65,25 +65,16 @@ export function SandboxWslInstall(props: { message: string }) {
- {/* A server that sent no content-length leaves the total at 0; - say what is happening rather than quote "of 0 MB". */} + {/* The size comes from the manifest, so it is known before a + byte arrives — there is no indeterminate case to cover. */}

- 0} - fallback={language.t("sandbox.wsl.downloadingUnknownSize", { - done: Math.round(current().bytes / 1024 / 1024), - })} - > - {language.t("sandbox.wsl.downloading", { - done: Math.round(current().bytes / 1024 / 1024), - total: Math.round(current().totalBytes / 1024 / 1024), - })} - + {language.t("sandbox.wsl.downloading", { + done: Math.round(current().bytes / 1024 / 1024), + total: Math.round(current().totalBytes / 1024 / 1024), + })}

)} diff --git a/packages/codev-sandbox/script/publish-wsl-installer.ts b/packages/codev-sandbox/script/publish-wsl-installer.ts new file mode 100644 index 0000000000..8dcace2f35 --- /dev/null +++ b/packages/codev-sandbox/script/publish-wsl-installer.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env bun +// Publishes the WSL runtime installers and their manifest to codev-storage. +// Usage: bun script/publish-wsl-installer.ts --version 2.7.11 [--in dist-wsl] +// +// Expects the MSIs Microsoft publishes for a release, named as they come: +// wsl..0.x64.msi, wsl..0.arm64.msi +// from https://github.com/microsoft/WSL/releases/tag/ +// +// Uses the MinIO client when CODEV_STORAGE_TARGET is set (an `mc` alias/bucket +// prefix, e.g. "codev/codev-storage"); otherwise prints the exact commands so +// the upload can run wherever the storage credentials live. The installers +// upload first and manifest.json last — clients only see a version once every +// file it names is in place. +import { $ } from "bun" +import { createHash } from "node:crypto" +import { createReadStream } from "node:fs" +import { stat, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { WSL_INSTALLER_MANIFEST_URL, sandboxStorageObjectKey, wslInstallerAssetUrl } from "../src/const" +import { parseWslInstallerManifest } from "../src/manifest" +import type { SandboxArch } from "../src/provider" + +const version = argValue("--version") +if (!version) { + console.error("--version is required (e.g. 2.7.11)") + process.exit(1) +} +const inDir = argValue("--in") ?? join(process.cwd(), "dist-wsl") + +// Every architecture, always. A manifest naming one of them retires the other: +// clients read this single document, and an arch it omits falls back to +// Microsoft's servers — which is exactly what the whitelisted fleet cannot +// reach. +const ARCHES: SandboxArch[] = ["x64", "arm64"] + +const assets: Record = {} +for (const arch of ARCHES) { + const url = wslInstallerAssetUrl(version, arch) + const name = url.split("/").pop()! + const file = join(inDir, name) + const found = await stat(file).catch(() => null) + if (!found?.isFile()) { + console.error(`missing ${name} in ${inDir}`) + console.error(`download it from https://github.com/microsoft/WSL/releases/tag/${version}`) + process.exit(1) + } + const hash = createHash("sha256") + for await (const chunk of createReadStream(file)) hash.update(chunk as Buffer) + assets[arch] = { url, sha256: hash.digest("hex"), bytes: found.size } + console.log(` ${arch}: ${(found.size / 1024 / 1024).toFixed(0)} MiB sha256 ${assets[arch]!.sha256.slice(0, 12)}…`) +} + +// Windows 10 2004 (19041) is Microsoft's floor for the MSI-packaged WSL, and +// Windows 11 is above it by construction. Recorded rather than enforced — +// nothing reads it yet. +const manifest = parseWslInstallerManifest({ schema: 1, version, recommendedMinBuild: 19041, assets }) +const manifestPath = join(inDir, "manifest.json") +await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) +console.log(`\nwrote ${manifestPath}`) + +const target = process.env.CODEV_STORAGE_TARGET +const uploads = [ + ...ARCHES.map((arch) => ({ + file: join(inDir, assets[arch]!.url.split("/").pop()!), + key: sandboxStorageObjectKey(assets[arch]!.url), + })), + { file: manifestPath, key: sandboxStorageObjectKey(WSL_INSTALLER_MANIFEST_URL) }, +] +if (!target) { + console.log("\nCODEV_STORAGE_TARGET is unset; run these where the storage credentials live:") + for (const upload of uploads) console.log(` mc cp ${upload.file} //${upload.key}`) + process.exit(0) +} +for (const upload of uploads) { + console.log(`uploading ${upload.key}...`) + await $`mc cp ${upload.file} ${target}/${upload.key}` +} +console.log(`\npublished WSL ${version}`) + +function argValue(flag: string) { + const index = process.argv.indexOf(flag) + return index >= 0 ? process.argv[index + 1] : undefined +} diff --git a/packages/codev-sandbox/src/const.ts b/packages/codev-sandbox/src/const.ts index 79f7049c3c..850af47971 100644 --- a/packages/codev-sandbox/src/const.ts +++ b/packages/codev-sandbox/src/const.ts @@ -31,34 +31,24 @@ export const INSTALLERS_BASE_URL = `${STORAGE_BASE}/installers` export const WSL_INSTALLER_ZIP_URL = `${INSTALLERS_BASE_URL}/wsl.zip` export const WSL_DISTRO_IMAGE_NAME = "ubuntu-24.04.4-wsl-amd64.wsl" -// The WSL runtime installers on their own, one per architecture. Published -// under the same `/codev` prefix as the sandbox images rather than beside -// wsl.zip: that zip is already uploaded at the older path, and moving it would -// break the distro install for no gain. +// The WSL runtime installers, published under the same `/codev` prefix as the +// sandbox images rather than beside wsl.zip: that zip is already uploaded at +// the older path, and moving it would break the distro install for no gain. // // Bare MSIs rather than a second zip because the sandbox needs only the // runtime — it brings its own rootfs and its own server — so there is nothing // to bundle alongside and nothing to extract. It is also what makes ARM64 // reachable at all: the zip is x64-only. -export const WSL_INSTALLER_MSI_BASE_URL = `${STORAGE_BASE}/codev/installers` - -// Version-pinned, and the x64 entry is deliberately the same file wsl.zip -// carries — same version, same name — so the two acquisition paths share one -// cache slot instead of fetching the same installer twice. 2.7.11 is the -// newest stable WSL release; it supports Windows 10 (build 19041+) and -// Windows 11 alike, which is what keeps one pin good for the whole fleet. -export const WSL_INSTALLER_MSI_NAMES = { - x64: "wsl.2.7.11.0.x64.msi", - arm64: "wsl.2.7.11.0.arm64.msi", -} as const - -export type WslInstallerArch = keyof typeof WSL_INSTALLER_MSI_NAMES - -// The zip's member name. Derived rather than repeated: if the pin moves, the -// bundle and the standalone download must move together or an x64 machine -// downloads two different versions of the same installer. -export const WSL_INSTALLER_MSI_NAME = WSL_INSTALLER_MSI_NAMES.x64 - -export function wslInstallerMsiName(arch: string) { - return arch === "x64" || arch === "arm64" ? WSL_INSTALLER_MSI_NAMES[arch] : undefined +export const WSL_INSTALLERS_BASE_URL = `${STORAGE_BASE}/codev/installers/wsl` + +// The pointer every client reads. Unversioned, like the image manifest, with +// the installers themselves under a per-version directory it names — so +// bumping WSL is an upload, not an app release, and the version pin lives with +// the files instead of in this file. +export const WSL_INSTALLER_MANIFEST_URL = `${WSL_INSTALLERS_BASE_URL}/manifest.json` + +// Where a given version's installers live. The manifest carries absolute URLs, +// so this is only used when building one. +export function wslInstallerAssetUrl(version: string, arch: "x64" | "arm64") { + return `${WSL_INSTALLERS_BASE_URL}/${version}/wsl.${version}.0.${arch}.msi` } diff --git a/packages/codev-sandbox/src/manifest.test.ts b/packages/codev-sandbox/src/manifest.test.ts index e44265f50b..040c8ceb74 100644 --- a/packages/codev-sandbox/src/manifest.test.ts +++ b/packages/codev-sandbox/src/manifest.test.ts @@ -1,5 +1,11 @@ import { expect, test } from "bun:test" -import { manifestImageKey, parseSandboxManifest, requireImageAsset } from "./manifest" +import { + manifestImageKey, + parseSandboxManifest, + parseWslInstallerManifest, + requireImageAsset, + requireWslInstallerAsset, +} from "./manifest" const asset = (name: string) => ({ url: `https://example.test/sandbox/${name}`, @@ -64,3 +70,39 @@ test("names missing images by backend and arch", () => { expect(() => requireImageAsset(manifest, "wsl", "x64")).toThrow("no image for wsl-x64") expect(manifestImageKey("wsl", "arm64")).toBe("wsl-arm64") }) + +test("the standalone WSL installer manifest parses and requires a digest", () => { + const manifest = parseWslInstallerManifest({ + schema: 1, + version: "2.7.11", + recommendedMinBuild: 19041, + assets: { + x64: { url: "https://example.test/wsl.2.7.11.0.x64.msi", sha256: "b".repeat(64), bytes: 258990080 }, + arm64: { url: "https://example.test/wsl.2.7.11.0.arm64.msi", sha256: "c".repeat(64), bytes: 257032192 }, + }, + }) + expect(manifest.version).toBe("2.7.11") + expect(requireWslInstallerAsset(manifest, "arm64").bytes).toBe(257032192) + // An arch we publish nothing for must throw, so the caller falls back to + // Microsoft's installer rather than staging a file that does not exist. + expect(() => requireWslInstallerAsset({ ...manifest, assets: {} }, "x64")).toThrow("x64") +}) + +test("an installer manifest without digests is refused", () => { + expect(() => + parseWslInstallerManifest({ + schema: 1, + version: "2.7.11", + recommendedMinBuild: 19041, + assets: { x64: { url: "https://example.test/wsl.msi", bytes: 10 } }, + }), + ).toThrow("sha256") +}) + +// The image manifest is regenerated on every image publish with wslInstaller +// null, which is why the installers carry their own document instead. +test("an unknown installer manifest schema is refused", () => { + expect(() => + parseWslInstallerManifest({ schema: 2, version: "2.7.11", recommendedMinBuild: 19041, assets: {} }), + ).toThrow("schema") +}) diff --git a/packages/codev-sandbox/src/manifest.ts b/packages/codev-sandbox/src/manifest.ts index 192b952ab5..af2fabed57 100644 --- a/packages/codev-sandbox/src/manifest.ts +++ b/packages/codev-sandbox/src/manifest.ts @@ -6,6 +6,10 @@ export type SandboxAsset = { bytes: number } +// The WSL runtime installers, one asset per architecture. `recommendedMinBuild` +// is the Windows build Microsoft supports this WSL version on; nothing reads it +// yet — the availability policy still carries its own floors — so it is +// recorded rather than enforced. export type SandboxWslInstaller = { version: string recommendedMinBuild: number @@ -16,9 +20,46 @@ export type SandboxManifest = { schema: 1 imageVersion: string images: Partial> + // Kept for compatibility, and deliberately not the source the WSL install + // reads: build-image.ts regenerates this whole document on every image + // publish, so an installer block recorded here is erased the next time the + // guest image is rebuilt. The installers ship their own manifest + // (WSL_INSTALLER_MANIFEST_URL) so the two release cadences stay independent. wslInstaller: SandboxWslInstaller | null } +// The standalone installer manifest: a SandboxWslInstaller with a schema tag, +// published beside the MSIs it points at. +export type WslInstallerManifest = SandboxWslInstaller & { schema: 1 } + +export function parseWslInstallerManifest(input: unknown): WslInstallerManifest { + if (!input || typeof input !== "object") throw new Error("WSL installer manifest must be an object") + const record = input as Record + if (record.schema !== 1) throw new Error(`unsupported WSL installer manifest schema: ${String(record.schema)}`) + const installer = parseWslInstaller(record) + if (!installer) throw new Error("WSL installer manifest is empty") + return { schema: 1, ...installer } +} + +export async function fetchWslInstallerManifest( + url: string, + opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal }, +): Promise { + const fetchImpl = opts?.fetchImpl ?? fetch + const response = await fetchImpl(url, { signal: opts?.signal }) + if (!response.ok) throw new Error(`WSL installer manifest download failed: HTTP ${response.status}`) + return parseWslInstallerManifest(await response.json()) +} + +export function requireWslInstallerAsset(manifest: WslInstallerManifest, arch: string) { + const asset = arch === "x64" || arch === "arm64" ? manifest.assets[arch] : undefined + // Throwing rather than returning nothing is what makes the caller fall back + // to Microsoft's installer, which publishes for every architecture Windows + // runs on. + if (!asset) throw new Error(`the WSL installer manifest has no ${arch} installer`) + return asset +} + export function manifestImageKey(backend: SandboxBackend, arch: SandboxArch) { return `${backend}-${arch}` } diff --git a/packages/desktop/src/main/wsl/install-bundle.test.ts b/packages/desktop/src/main/wsl/install-bundle.test.ts index dfca9aa387..d0e6c6fffc 100644 --- a/packages/desktop/src/main/wsl/install-bundle.test.ts +++ b/packages/desktop/src/main/wsl/install-bundle.test.ts @@ -1,13 +1,9 @@ import { expect, test } from "bun:test" +import { createHash } from "node:crypto" import { mkdtemp, readFile, readdir } from "node:fs/promises" import { tmpdir } from "node:os" -import { join } from "node:path" -import { - WSL_DISTRO_IMAGE_NAME, - WSL_INSTALLER_MSI_BASE_URL, - WSL_INSTALLER_MSI_NAME, - WSL_INSTALLER_MSI_NAMES, -} from "@codev/sandbox" +import { basename, join } from "node:path" +import { WSL_DISTRO_IMAGE_NAME, wslInstallerAssetUrl } from "@codev/sandbox" import type { WslInstallProgress } from "./install-bundle" import { bundleProvidesDistro, @@ -22,12 +18,158 @@ async function fixtureZip() { const { BlobWriter, TextReader, ZipWriter, configure } = await import("@zip.js/zip.js") configure({ useWebWorkers: false }) const writer = new ZipWriter(new BlobWriter("application/zip")) - await writer.add(WSL_INSTALLER_MSI_NAME, new TextReader("msi-bytes")) + await writer.add("wsl.2.7.11.0.x64.msi", new TextReader("msi-bytes")) await writer.add(WSL_DISTRO_IMAGE_NAME, new TextReader("distro-bytes")) return writer.close() } -test("downloads, extracts by member name, and never fetches twice", async () => { +const MANIFEST_URL = "https://example.test/installers/wsl/manifest.json" + +// A real MSI is a quarter of a gigabyte; 64 deterministic bytes exercise the +// same digest-and-resume path. +function fixtureInstaller(arch: "x64" | "arm64") { + const bytes = new Uint8Array(64).map((_, index) => (index * 7) % 251) + return { + bytes, + url: wslInstallerAssetUrl("2.7.11", arch), + sha256: createHash("sha256").update(bytes).digest("hex"), + } +} + +function fixtureManifest(arch: "x64" | "arm64") { + const installer = fixtureInstaller(arch) + return { + installer, + manifest: { + schema: 1, + version: "2.7.11", + recommendedMinBuild: 19041, + assets: { [arch]: { url: installer.url, sha256: installer.sha256, bytes: installer.bytes.length } }, + }, + } +} + +test("stages the installer this machine's architecture needs", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const { installer, manifest } = fixtureManifest("arm64") + const asked: string[] = [] + const fetchImpl = (async (url: string) => { + asked.push(url) + return url.endsWith("manifest.json") ? Response.json(manifest) : new Response(installer.bytes) + }) as unknown as typeof fetch + + const staged = await ensureWslRuntimeMsi({ dir, arch: "arm64", manifestUrl: MANIFEST_URL, fetchImpl }) + + expect(basename(staged)).toBe(basename(new URL(installer.url).pathname)) + expect(new Uint8Array(await readFile(staged))).toEqual(installer.bytes) + expect(asked).toEqual([MANIFEST_URL, installer.url]) +}) + +// Content-addressed staging is what makes a second call free, and it is also +// what lets a future version land beside this one rather than over it. +test("a staged installer is not downloaded again", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const { installer, manifest } = fixtureManifest("x64") + let downloads = 0 + const fetchImpl = (async (url: string) => { + if (url.endsWith("manifest.json")) return Response.json(manifest) + downloads += 1 + return new Response(installer.bytes) + }) as unknown as typeof fetch + + const first = await ensureWslRuntimeMsi({ dir, arch: "x64", manifestUrl: MANIFEST_URL, fetchImpl }) + const second = await ensureWslRuntimeMsi({ dir, arch: "x64", manifestUrl: MANIFEST_URL, fetchImpl }) + + expect(second).toBe(first) + expect(downloads).toBe(1) +}) + +// The reason this goes through stageAsset at all: 250MB over a link observed +// at ~35 KB/s cannot start again from zero every time a connection drops. +test("an interrupted download resumes instead of restarting", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const { installer, manifest } = fixtureManifest("x64") + const ranges: (string | undefined)[] = [] + const fetchImpl = (async (url: string, init?: RequestInit) => { + if (url.endsWith("manifest.json")) return Response.json(manifest) + const range = (init?.headers as Record | undefined)?.range + ranges.push(range) + if (!range) { + // Half the file, then the connection drops mid-body. Pull-based rather + // than enqueue-then-error, which discards the queued chunk and would + // leave nothing on disk to resume from. + let sent = false + return new Response( + new ReadableStream({ + pull(controller) { + if (sent) return controller.error(new Error("connection reset")) + sent = true + controller.enqueue(installer.bytes.slice(0, 32)) + }, + }), + ) + } + const offset = Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0) + return new Response(installer.bytes.slice(offset), { status: 206 }) + }) as unknown as typeof fetch + + const staged = await ensureWslRuntimeMsi({ dir, arch: "x64", manifestUrl: MANIFEST_URL, fetchImpl }) + + // The second attempt asked for the remainder, not the whole file again. + expect(ranges).toEqual([undefined, "bytes=32-"]) + expect(new Uint8Array(await readFile(staged))).toEqual(installer.bytes) +}) + +// A truncated or swapped installer must never reach msiexec. +test("an installer that does not match its digest is rejected", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const { installer, manifest } = fixtureManifest("x64") + const fetchImpl = (async (url: string) => + url.endsWith("manifest.json") + ? Response.json(manifest) + : new Response(new Uint8Array(installer.bytes.length))) as unknown as typeof fetch + + expect(ensureWslRuntimeMsi({ dir, arch: "x64", manifestUrl: MANIFEST_URL, fetchImpl })).rejects.toThrow("sha256") +}) + +test("reports download progress against the manifest's size", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const { installer, manifest } = fixtureManifest("x64") + const fetchImpl = (async (url: string) => + url.endsWith("manifest.json") ? Response.json(manifest) : new Response(installer.bytes)) as unknown as typeof fetch + const seen: WslInstallProgress[] = [] + + await ensureWslRuntimeMsi({ + dir, + arch: "x64", + manifestUrl: MANIFEST_URL, + fetchImpl, + onProgress: (progress) => seen.push(progress), + }) + + // Reporting is interval-based, so only the final report is guaranteed — and + // the total comes from the manifest, so it is known before a byte arrives. + expect(seen.at(-1)).toEqual({ phase: "downloading", bytes: 64, totalBytes: 64 }) +}) + +// Falling through to Microsoft's installer is the right answer when we publish +// nothing for this machine, so these have to throw rather than resolve. +test("an architecture the manifest does not cover throws so the caller falls back", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const { manifest } = fixtureManifest("x64") + const fetchImpl = (async () => Response.json(manifest)) as unknown as typeof fetch + + expect(ensureWslRuntimeMsi({ dir, arch: "arm64", manifestUrl: MANIFEST_URL, fetchImpl })).rejects.toThrow("arm64") +}) + +test("an unreachable manifest throws with its status", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) + const fetchImpl = (async () => new Response("nope", { status: 404 })) as unknown as typeof fetch + + expect(ensureWslRuntimeMsi({ dir, arch: "x64", manifestUrl: MANIFEST_URL, fetchImpl })).rejects.toThrow("404") +}) + +test("downloads and extracts the distro image, and never fetches twice", async () => { const dir = await mkdtemp(join(tmpdir(), "wsl-bundle-")) const zip = await fixtureZip() let fetches = 0 @@ -37,22 +179,24 @@ test("downloads, extracts by member name, and never fetches twice", async () => }) as typeof fetch const first = await ensureWslInstallBundle({ dir, fetchImpl }) - expect(await readFile(first.msiPath, "utf8")).toBe("msi-bytes") expect(await readFile(first.distroImagePath, "utf8")).toBe("distro-bytes") - // The zip is deleted after extraction and nothing half-written survives. - expect((await readdir(dir)).toSorted()).toEqual([WSL_DISTRO_IMAGE_NAME, WSL_INSTALLER_MSI_NAME].toSorted()) + // The zip is deleted after extraction and nothing half-written survives. Its + // x64 runtime MSI is deliberately left in the zip: the runtime comes from the + // installer manifest now, so extracting it would cost 250MB of disk for a + // file with no reader. + expect(await readdir(dir)).toEqual([WSL_DISTRO_IMAGE_NAME]) const second = await ensureWslInstallBundle({ dir, fetchImpl }) expect(second).toEqual(first) expect(fetches).toBe(1) }) -test("fails loudly when the zip is missing a member", async () => { +test("fails loudly when the zip is missing the distro image", async () => { const dir = await mkdtemp(join(tmpdir(), "wsl-bundle-")) const { BlobWriter, TextReader, ZipWriter, configure } = await import("@zip.js/zip.js") configure({ useWebWorkers: false }) const writer = new ZipWriter(new BlobWriter("application/zip")) - await writer.add(WSL_INSTALLER_MSI_NAME, new TextReader("msi-bytes")) + await writer.add("wsl.2.7.11.0.x64.msi", new TextReader("msi-bytes")) const zip = await writer.close() const fetchImpl = (async () => new Response(zip)) as typeof fetch @@ -74,84 +218,3 @@ test("only Ubuntu-24.04 is served from the bundle", () => { expect(bundleProvidesDistro("Ubuntu-22.04")).toBe(false) expect(bundleProvidesDistro("Debian")).toBe(false) }) - -test("downloads the MSI for this machine's architecture and caches it", async () => { - const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) - const asked: string[] = [] - const fetchImpl = (async (url: string) => { - asked.push(url) - return new Response("msi-bytes", { headers: { "content-length": "9" } }) - }) as unknown as typeof fetch - - const first = await ensureWslRuntimeMsi({ dir, arch: "arm64", fetchImpl }) - expect(first).toBe(join(dir, WSL_INSTALLER_MSI_NAMES.arm64)) - expect(await readFile(first, "utf8")).toBe("msi-bytes") - expect(asked).toEqual([`${WSL_INSTALLER_MSI_BASE_URL}/${WSL_INSTALLER_MSI_NAMES.arm64}`]) - // Nothing half-written survives a completed download. - expect(await readdir(dir)).toEqual([WSL_INSTALLER_MSI_NAMES.arm64]) - - expect(await ensureWslRuntimeMsi({ dir, arch: "arm64", fetchImpl })).toBe(first) - expect(asked.length).toBe(1) -}) - -// The x64 MSI is the same file wsl.zip carries, under the same name, so a -// machine that already pulled the bundle for a WSL server must not download -// the installer a second time. -test("the x64 MSI shares its cache slot with the bundle's copy", async () => { - const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) - const zip = await fixtureZip() - const fetchImpl = (async () => new Response(zip)) as typeof fetch - await ensureWslInstallBundle({ dir, fetchImpl }) - - const failIfFetched = (async () => { - throw new Error("should not download") - }) as unknown as typeof fetch - expect(await ensureWslRuntimeMsi({ dir, arch: "x64", fetchImpl: failIfFetched })).toBe( - join(dir, WSL_INSTALLER_MSI_NAME), - ) -}) - -test("reports download progress against the advertised size", async () => { - const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) - const body = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(4)) - controller.enqueue(new Uint8Array(6)) - controller.close() - }, - }) - const fetchImpl = (async () => new Response(body, { headers: { "content-length": "10" } })) as unknown as typeof fetch - const seen: WslInstallProgress[] = [] - - await ensureWslRuntimeMsi({ dir, arch: "x64", fetchImpl, onProgress: (progress) => seen.push(progress) }) - - // Reporting is interval-based, so only the flush at the end is guaranteed — - // and it must land on the full size rather than stall short of it. - expect(seen.at(-1)).toEqual({ phase: "downloading", bytes: 10, totalBytes: 10 }) -}) - -// A server that sends no content-length must not leave the UI dividing by zero -// forever; the final report falls back to what actually arrived. -test("a sizeless response still finishes at 100%", async () => { - const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) - const fetchImpl = (async () => new Response("msi-bytes")) as unknown as typeof fetch - const seen: WslInstallProgress[] = [] - - await ensureWslRuntimeMsi({ dir, arch: "x64", fetchImpl, onProgress: (progress) => seen.push(progress) }) - - expect(seen.at(-1)).toEqual({ phase: "downloading", bytes: 9, totalBytes: 9 }) -}) - -// Falling through to Microsoft's installer is the right answer for an -// architecture we publish nothing for, so this has to throw rather than -// resolve to a path that does not exist. -test("an unpublished architecture throws so the caller falls back", async () => { - const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) - expect(ensureWslRuntimeMsi({ dir, arch: "ia32" })).rejects.toThrow("ia32") -}) - -test("a missing MSI on the storage host throws with its status", async () => { - const dir = await mkdtemp(join(tmpdir(), "wsl-msi-")) - const fetchImpl = (async () => new Response("nope", { status: 404 })) as unknown as typeof fetch - expect(ensureWslRuntimeMsi({ dir, arch: "x64", fetchImpl })).rejects.toThrow("404") -}) diff --git a/packages/desktop/src/main/wsl/install-bundle.ts b/packages/desktop/src/main/wsl/install-bundle.ts index f76c3aae12..af575478d4 100644 --- a/packages/desktop/src/main/wsl/install-bundle.ts +++ b/packages/desktop/src/main/wsl/install-bundle.ts @@ -5,10 +5,11 @@ import { Writable } from "node:stream" import electron from "electron" import { WSL_DISTRO_IMAGE_NAME, - WSL_INSTALLER_MSI_BASE_URL, - WSL_INSTALLER_MSI_NAME, + WSL_INSTALLER_MANIFEST_URL, WSL_INSTALLER_ZIP_URL, - wslInstallerMsiName, + fetchWslInstallerManifest, + requireWslInstallerAsset, + stageAsset, } from "@codev/sandbox" import type { RunWslOptions, WslCommandResult } from "./runtime" import { @@ -26,13 +27,15 @@ import { // fallback: both paths need the same elevation, and a second UAC prompt right // after a failed one helps nobody. // -// Two things are published there, for two callers. The runtime is a standalone -// per-architecture MSI — the sandbox needs only that, since it imports its own -// rootfs and mounts its own server — and it is what makes ARM64 reachable at -// all. The distro image for WSL *servers* still travels in the x64-only -// wsl.zip alongside a copy of the same x64 MSI, which is why the two -// acquisition paths deliberately share one cache directory and one filename: -// whichever ran first, the other finds the installer already there. +// Two things are published there, for two callers. The runtime is a +// per-architecture MSI behind its own manifest, staged through the same +// resumable, digest-verified downloader the guest images use — a 250MB +// installer over a link measured in tens of KB/s cannot afford to restart from +// zero when a connection drops. The sandbox needs only that, since it imports +// its own rootfs and mounts its own server, and it is what makes ARM64 +// reachable at all. The distro image for WSL *servers* still travels in the +// x64-only wsl.zip, which is fetched whole because nothing there is +// content-addressed. // What the distro image registers itself as (its embedded // /etc/wsl-distribution.conf shortname), and also the name Microsoft's online @@ -57,10 +60,10 @@ export type WslInstallProgress = { phase: "downloading"; bytes: number; totalByt export type InstallBundleOptions = RunWslOptions & { dir?: string url?: string - // Overrides the standalone runtime MSI's URL. Separate from `url`, which + // Overrides the runtime installer manifest's URL. Separate from `url`, which // addresses the distro bundle's zip; a caller can be pointed at a fixture // for one without redirecting the other. - msiUrl?: string + manifestUrl?: string arch?: string fetchImpl?: typeof fetch onProgress?: (progress: WslInstallProgress) => void @@ -137,40 +140,45 @@ export function wslRuntimeInstallScript(msiPath: string) { ].join("\r\n") } -// The runtime installer for this machine's architecture, downloaded on its -// own. Cached in the same directory wsl.zip extracts into and under the same -// filename, so an x64 machine that already pulled the bundle for a WSL server -// finds the MSI here and downloads nothing. +// The runtime installer for this machine's architecture. Staged through +// stageAsset, which gives this the same resumable, content-addressed, +// digest-verified download the guest images get: an interrupted transfer +// resumes from where it stopped rather than starting the 250MB again, and a +// truncated or corrupted file is rejected before it reaches msiexec. export async function ensureWslRuntimeMsi(opts?: InstallBundleOptions) { - const arch = opts?.arch ?? process.arch - const name = wslInstallerMsiName(arch) - // Throws rather than returning nothing so the caller falls back to - // Microsoft's installer, which does publish for every architecture Windows - // runs on. - if (!name) throw new Error(`no WSL installer is published for ${arch}`) + const manifest = await fetchWslInstallerManifest(opts?.manifestUrl ?? WSL_INSTALLER_MANIFEST_URL, { + fetchImpl: opts?.fetchImpl, + signal: opts?.signal, + }) + const asset = requireWslInstallerAsset(manifest, opts?.arch ?? process.arch) + // Its own cache directory, not the guest images': stageAsset's partial + // bookkeeping assumes a single writer, and sandbox image downloads are + // driven by a different queue than this. const dir = opts?.dir ?? join(electron.app.getPath("userData"), "wsl-installer") - const msiPath = join(dir, name) - if (existsSync(msiPath)) return msiPath - await mkdir(dir, { recursive: true }) - await downloadTo(msiPath, opts?.msiUrl ?? `${WSL_INSTALLER_MSI_BASE_URL}/${name}`, opts) - return msiPath + return stageAsset(dir, asset, { + fetchImpl: opts?.fetchImpl, + signal: opts?.signal, + onProgress: opts?.onProgress ? (progress) => opts.onProgress?.({ phase: "downloading", ...progress }) : undefined, + }) } +// Only the distro image is taken from the zip. It also carries a copy of the +// x64 runtime MSI, which nothing reads any more — the runtime comes from the +// installer manifest, for every architecture — so extracting it would cost a +// quarter of a gigabyte of disk to produce a file with no reader. export async function ensureWslInstallBundle(opts?: InstallBundleOptions) { const dir = opts?.dir ?? join(electron.app.getPath("userData"), "wsl-installer") - const msiPath = join(dir, WSL_INSTALLER_MSI_NAME) const distroImagePath = join(dir, WSL_DISTRO_IMAGE_NAME) - if (existsSync(msiPath) && existsSync(distroImagePath)) return { msiPath, distroImagePath } + if (existsSync(distroImagePath)) return { distroImagePath } await mkdir(dir, { recursive: true }) const zipPath = join(dir, "wsl.zip") if (!existsSync(zipPath)) await downloadTo(zipPath, opts?.url ?? WSL_INSTALLER_ZIP_URL, opts) - await extractZipMembers(zipPath, dir, [WSL_INSTALLER_MSI_NAME, WSL_DISTRO_IMAGE_NAME]) - if (!existsSync(msiPath) || !existsSync(distroImagePath)) - throw new Error(`wsl.zip did not contain ${WSL_INSTALLER_MSI_NAME} and ${WSL_DISTRO_IMAGE_NAME}`) + await extractZipMembers(zipPath, dir, [WSL_DISTRO_IMAGE_NAME]) + if (!existsSync(distroImagePath)) throw new Error(`wsl.zip did not contain ${WSL_DISTRO_IMAGE_NAME}`) // Members extracted, so the zip is 643MB of dead weight from here on. await rm(zipPath, { force: true }) - return { msiPath, distroImagePath } + return { distroImagePath } } async function downloadTo(destination: string, url: string, opts?: InstallBundleOptions) { @@ -183,37 +191,10 @@ async function downloadTo(destination: string, url: string, opts?: InstallBundle const response = await fetchImpl(url, { signal }) if (!response.ok || !response.body) throw new Error(`downloading ${url} failed: HTTP ${response.status}`) const partial = `${destination}.partial` - const onProgress = opts?.onProgress - // A missing content-length leaves totalBytes at 0; the UI reads that as - // "size unknown" and shows an indeterminate state rather than dividing by it. - const totalBytes = Number(response.headers.get("content-length") ?? 0) - const body = onProgress ? response.body.pipeThrough(countingStream(totalBytes, onProgress)) : response.body - await body.pipeTo(Writable.toWeb(createWriteStream(partial)), { signal }) + await response.body.pipeTo(Writable.toWeb(createWriteStream(partial)), { signal }) await rename(partial, destination) } -// Progress crosses an IPC boundary, so it is reported on an interval rather -// than per chunk — a 250MB installer is tens of thousands of chunks. -const PROGRESS_INTERVAL_MS = 200 - -function countingStream(totalBytes: number, onProgress: (progress: WslInstallProgress) => void) { - let bytes = 0 - let lastReport = 0 - return new TransformStream({ - transform(chunk, controller) { - bytes += chunk.byteLength - if (Date.now() - lastReport >= PROGRESS_INTERVAL_MS) { - lastReport = Date.now() - onProgress({ phase: "downloading", bytes, totalBytes }) - } - controller.enqueue(chunk) - }, - flush() { - onProgress({ phase: "downloading", bytes, totalBytes: totalBytes || bytes }) - }, - }) -} - // zip.js instead of PowerShell Expand-Archive: streams each member to disk // without buffering it, and keeps this file testable off Windows. async function extractZipMembers(zipPath: string, dir: string, names: string[]) { From 878633ed74f7203117a4ba96546acbf39ddf6a4d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 18 Aug 2026 17:00:42 +0700 Subject: [PATCH 3/3] feat(sandbox): warn when Windows is below the installer's minimum build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommendedMinBuild was recorded in the installer manifest and read by nobody; the availability warning carried its own hardcoded tiers instead. A machine below what the published installer supports was told the generic "this Windows version is out of support" line, or on 19044 nothing specific at all, and only found out for certain when the install failed. windowsSandboxAvailability now takes the manifest's floor and, when the machine is under it, names both builds — the one it has and the one it needs. That is more specific than the tiers, so it wins over them; a machine above the floor keeps the tier it was already in, since satisfying what WSL needs to install says nothing about the sandbox on Windows 10. Windows stays warn-never-block throughout: an old build may still work, and a real incompatibility surfaces as an install error rather than a refusal. The floor reaches the provider as resolveInstallerMinBuild, injected the way resolveImage and resolveEgress already are, so the provider keeps no URLs and no fetch policy of its own. The desktop caches it for the app's lifetime and bounds it at 5s, and only asks below the first Windows 11 build — every Windows 11 build clears every floor Microsoft has published, so the common machine pays no round trip. An unreachable manifest resolves null and leaves the policy exactly where it was before the manifest existed. Raising the floor is now an upload: publishing a WSL version with a higher recommendedMinBuild re-words the warning without an app release. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 +- .../script/publish-wsl-installer.ts | 4 +- packages/codev-sandbox/src/manifest.ts | 7 +-- packages/codev-sandbox/src/policy.test.ts | 30 ++++++++++++ packages/codev-sandbox/src/policy.ts | 23 ++++++++- .../codev-sandbox/src/wsl/provider.test.ts | 49 +++++++++++++++++++ packages/codev-sandbox/src/wsl/provider.ts | 17 ++++++- .../desktop/src/main/sandbox/controller.ts | 30 +++++++++++- 8 files changed, 154 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2885e35d4b..acb999277b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,9 @@ file is the human-readable summary of what changed and why. cut off resumes where it stopped instead of starting the quarter-gigabyte again, and an installer that arrives damaged is rejected rather than run. ARM64 computers are covered for the first time; the older bundled installer - was Intel-only. + was Intel-only. A computer whose Windows is too old for the installer is now + told so — naming its build and the one it needs — before it downloads + anything, instead of after the install fails. - **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 diff --git a/packages/codev-sandbox/script/publish-wsl-installer.ts b/packages/codev-sandbox/script/publish-wsl-installer.ts index 8dcace2f35..024e1a371b 100644 --- a/packages/codev-sandbox/script/publish-wsl-installer.ts +++ b/packages/codev-sandbox/script/publish-wsl-installer.ts @@ -51,8 +51,8 @@ for (const arch of ARCHES) { } // Windows 10 2004 (19041) is Microsoft's floor for the MSI-packaged WSL, and -// Windows 11 is above it by construction. Recorded rather than enforced — -// nothing reads it yet. +// Windows 11 is above it by construction. The app warns machines below this +// before they try, so raising it here changes what they are told. const manifest = parseWslInstallerManifest({ schema: 1, version, recommendedMinBuild: 19041, assets }) const manifestPath = join(inDir, "manifest.json") await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) diff --git a/packages/codev-sandbox/src/manifest.ts b/packages/codev-sandbox/src/manifest.ts index af2fabed57..92e84564cc 100644 --- a/packages/codev-sandbox/src/manifest.ts +++ b/packages/codev-sandbox/src/manifest.ts @@ -7,9 +7,10 @@ export type SandboxAsset = { } // The WSL runtime installers, one asset per architecture. `recommendedMinBuild` -// is the Windows build Microsoft supports this WSL version on; nothing reads it -// yet — the availability policy still carries its own floors — so it is -// recorded rather than enforced. +// is the Windows build Microsoft supports this WSL version on; the availability +// warning reads it, so publishing a WSL version that raises the floor re-words +// that warning without an app release. Still advisory — Windows stays +// warn-never-block. export type SandboxWslInstaller = { version: string recommendedMinBuild: number diff --git a/packages/codev-sandbox/src/policy.test.ts b/packages/codev-sandbox/src/policy.test.ts index cdb78486c4..c7a4997294 100644 --- a/packages/codev-sandbox/src/policy.test.ts +++ b/packages/codev-sandbox/src/policy.test.ts @@ -7,6 +7,7 @@ import { linuxSandboxAvailability, macosSandboxAvailability, windowsBuildNumber, + windowsInstallerBuildWarning, windowsSandboxAvailability, } from "./policy" @@ -51,3 +52,32 @@ test("sandbox resource limits accept sane values and reject the rest", () => { expect(sandboxResourcesValid({ memoryMiB: 131072 })).toBe(false) expect(sandboxResourcesValid({ cpus: Number.NaN })).toBe(false) }) + +// The installer's own floor comes from the WSL installer manifest, so raising +// it is an upload rather than a release. More specific than the tiers, so it +// wins over them — and still warn-never-block. +test("warns against the installer's own minimum build when the machine is below it", () => { + expect(windowsSandboxAvailability(18363, 19041)).toEqual({ + supported: true, + warning: windowsInstallerBuildWarning(18363, 19041), + }) + // Both builds are named: "update Windows" is not actionable without knowing + // how far. + expect(windowsInstallerBuildWarning(18363, 19041)).toContain("18363") + expect(windowsInstallerBuildWarning(18363, 19041)).toContain("19041") +}) + +test("a machine above the installer's minimum keeps the tier it was already in", () => { + // Satisfying what WSL needs to install says nothing about the sandbox on + // Windows 10, so that warning stands. + expect(windowsSandboxAvailability(19045, 19041)).toEqual({ supported: true, warning: WINDOWS_10_WARNING }) + expect(windowsSandboxAvailability(22631, 19041)).toEqual({ supported: true, warning: null }) +}) + +// Absent whenever the manifest could not be read — an offline launch, or a +// machine new enough that asking was not worth a round trip. +test("an unknown installer minimum falls back to the build tiers", () => { + expect(windowsSandboxAvailability(19044, null)).toEqual({ supported: true, warning: WINDOWS_OLD_WARNING }) + expect(windowsSandboxAvailability(19044, undefined)).toEqual({ supported: true, warning: WINDOWS_OLD_WARNING }) + expect(windowsSandboxAvailability(null, 19041)).toEqual({ supported: true, warning: WINDOWS_OLD_WARNING }) +}) diff --git a/packages/codev-sandbox/src/policy.ts b/packages/codev-sandbox/src/policy.ts index 94229eb9f2..26369debee 100644 --- a/packages/codev-sandbox/src/policy.ts +++ b/packages/codev-sandbox/src/policy.ts @@ -26,9 +26,30 @@ export function macosSandboxAvailability(productVersion: string): SandboxAvailab return { supported: true, warning: null } } -export function windowsSandboxAvailability(buildNumber: number | null): SandboxAvailability { +// What the published WSL installer needs, when the machine is below it. More +// specific than the tiers below — it names the build and why — so it wins over +// them, and it moves with the manifest rather than with a release: publishing +// a WSL version that raises the floor re-words this warning by itself. +export function windowsInstallerBuildWarning(buildNumber: number, requiredBuild: number) { + return `This Windows build (${buildNumber}) is older than build ${requiredBuild}, which the WSL installer requires. Setting up the sandbox is likely to fail; update Windows first.` +} + +// recommendedMinBuild comes from the WSL installer manifest and is absent +// whenever it could not be read — an offline launch, or a machine new enough +// that asking was not worth a round trip. Absent means fall back to the tiers, +// which is exactly what this did before the manifest existed. +export function windowsSandboxAvailability( + buildNumber: number | null, + recommendedMinBuild?: number | null, +): SandboxAvailability { if (buildNumber === null) return { supported: true, warning: WINDOWS_OLD_WARNING } + // Warn-never-block still holds: an old build may yet work, and a real + // incompatibility surfaces as a clear install error rather than a refusal. + if (recommendedMinBuild && buildNumber < recommendedMinBuild) + return { supported: true, warning: windowsInstallerBuildWarning(buildNumber, recommendedMinBuild) } if (buildNumber >= WINDOWS_11_FIRST_BUILD) return { supported: true, warning: null } + // Kept even when the installer's own floor is satisfied: this one is about + // the sandbox on Windows 10, not about what WSL will install on. if (buildNumber >= WINDOWS_10_22H2_BUILD) return { supported: true, warning: WINDOWS_10_WARNING } return { supported: true, warning: WINDOWS_OLD_WARNING } } diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index d3b8609a2c..e58ff5b345 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -130,6 +130,55 @@ test("availability is clean on Windows 11", async () => { await rm(dir, { recursive: true, force: true }) }) +test("availability warns against the installer's minimum build on an older machine", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider( + await preparedOptions(dir, { + osRelease: () => "10.0.18363", + resolveInstallerMinBuild: async () => 19041, + }), + ) + const availability = await provider.availability() + expect(availability.supported).toBe(true) + expect(availability.supported && availability.warning).toContain("19041") + await rm(dir, { recursive: true, force: true }) +}) + +// Every Windows 11 build clears every floor Microsoft has published, so the +// common machine must not pay a network round trip to learn that. +test("availability does not ask for the installer minimum on Windows 11", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + let asked = 0 + const provider = createWslProvider( + await preparedOptions(dir, { + resolveInstallerMinBuild: async () => { + asked += 1 + return 19041 + }, + }), + ) + expect(await provider.availability()).toEqual({ supported: true, warning: null }) + expect(asked).toBe(0) + await rm(dir, { recursive: true, force: true }) +}) + +// A storage host that cannot be reached must sharpen nothing and break +// nothing; the machine still gets the tier it had before the manifest existed. +test("availability survives an unreachable installer manifest", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider( + await preparedOptions(dir, { + osRelease: () => "10.0.19045", + resolveInstallerMinBuild: async () => { + throw new Error("offline") + }, + }), + ) + const availability = await provider.availability() + expect(availability.supported && availability.warning).toContain("Windows 10") + await rm(dir, { recursive: true, force: true }) +}) + test("availability reports unsupported when wsl --version fails", async () => { const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) const missing = join(dir, "missing-wsl.mjs") diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index 6bedbcb191..4717bb79db 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -13,7 +13,7 @@ import { createBrokerServer, type SandboxEgress } from "../broker" import { localImageAsset, readImageMarker, stageAsset, writeImageMarker } from "../image" import { wslFirewallScript } from "./firewall" import type { SandboxAsset } from "../manifest" -import { windowsBuildNumber, windowsSandboxAvailability } from "../policy" +import { WINDOWS_11_FIRST_BUILD, windowsBuildNumber, windowsSandboxAvailability } from "../policy" import type { ResolvedSandboxMount, SandboxAvailability, @@ -48,6 +48,11 @@ export type WslProviderOptions = { // the guest loopback — per-sandbox here because every WSL2 distro shares the // VM loopback, so a fixed port would collide across concurrent sandboxes. resolveEgress?: (opts: { guestBrokerPort: number }) => Promise + // The Windows build the published WSL installer needs, for the availability + // warning. Injected like resolveImage so the provider keeps no URLs and no + // fetch policy of its own; absent, or resolving null, falls back to the + // policy's own build tiers. + resolveInstallerMinBuild?: (opts?: { signal?: AbortSignal }) => Promise wslCommand?: string platform?: NodeJS.Platform osRelease?: () => string @@ -171,7 +176,15 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider supported: false, message: "WSL 2 is not installed. Install it to use the sandbox, then try again.", } - return windowsSandboxAvailability(windowsBuildNumber(osRelease())) + 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 + // machine this costs no round trip at all. + const recommended = + build !== null && build < WINDOWS_11_FIRST_BUILD + ? await options.resolveInstallerMinBuild?.().catch(() => null) + : null + return windowsSandboxAvailability(build, recommended) }, async create(config, opts) { diff --git a/packages/desktop/src/main/sandbox/controller.ts b/packages/desktop/src/main/sandbox/controller.ts index 57b0774afc..83c5ffa811 100644 --- a/packages/desktop/src/main/sandbox/controller.ts +++ b/packages/desktop/src/main/sandbox/controller.ts @@ -3,11 +3,13 @@ import { fileURLToPath } from "node:url" import { app } from "electron" import { SANDBOX_MANIFEST_URL, + WSL_INSTALLER_MANIFEST_URL, createBwrapProvider, createSandboxController, createVzProvider, createWslProvider, fetchSandboxManifest, + fetchWslInstallerManifest, requireImageAsset, sandboxResourcesValid, } from "@codev/sandbox" @@ -66,11 +68,37 @@ function platformProvider(): SandboxProvider | undefined { // tarball as bubblewrap (wsl --import brings its own kernel), so both // resolve the bwrap asset and only vz needs its own kernel+disk image. if (process.platform === "darwin") return createVzProvider({ ...options("vz"), helperPath: vmmHelperPath() }) - if (process.platform === "win32") return createWslProvider(options("bwrap")) + if (process.platform === "win32") + return createWslProvider({ ...options("bwrap"), resolveInstallerMinBuild: cachedInstallerMinBuild() }) if (process.platform === "linux") return createBwrapProvider(options("bwrap")) return undefined } +// The Windows build the published WSL installer needs, for the availability +// warning. Availability is asked on every launch and its verdict is cached for +// the app's lifetime, so this is bounded and cached too — and only successes +// are kept, so a machine that was offline at launch still gets a real answer +// later. Failure resolves null, which leaves the policy on its own build tiers. +function cachedInstallerMinBuild() { + let cached: number | undefined + return async (opts?: { signal?: AbortSignal }) => { + if (cached !== undefined) return cached + const signal = AbortSignal.any( + [opts?.signal, AbortSignal.timeout(INSTALLER_MANIFEST_TIMEOUT_MS)].filter( + (value): value is AbortSignal => value !== undefined, + ), + ) + const manifest = await fetchWslInstallerManifest(WSL_INSTALLER_MANIFEST_URL, { signal }).catch(() => null) + if (!manifest) return null + cached = manifest.recommendedMinBuild + return cached + } +} + +// Short on purpose: this only sharpens a warning, and it sits in front of a +// dialog the user is waiting on. +const INSTALLER_MANIFEST_TIMEOUT_MS = 5_000 + function sandboxArch(): SandboxArch | undefined { if (process.arch === "x64" || process.arch === "arm64") return process.arch return undefined