Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,33 @@ file is the human-readable summary of what changed and why.
told so — naming its build and the one it needs — before it downloads
anything, instead of after the install fails.

- **Installing WSL now says when Windows must restart first** — on a computer
that never had WSL 2, the install enables a Windows feature that only starts
working after a restart. The app declared the install finished — WSL answers
a version query before that restart — and carried straight on to creating
the sandbox, which failed with a bare `exited 4294967295` from
`wsl --import` and nothing to go on. The
installer now recognizes Windows' restart-required answer and the dialog
says to restart Windows and come back, in both the sandbox and the Code
side's WSL install. The create-sandbox dialogs also offer a **Restart
Windows** button right there, so you are not sent to the Start menu
mid-flow: it asks you to confirm first — restarting closes CoDev and every
other open application — and an application holding unsaved work can still
hold the restart, because Windows is asked to restart, not forced to.
Windows 10 needed one more thing the installer never enabled: the Windows
Subsystem for Linux component itself, which Windows 10 requires even for
the standalone WSL the app installs — so a Windows 10 machine failed the
same way even after restarting. The installer enables it now, and the
"is WSL ready" check behind the dialogs asks WSL to actually stand up
(`wsl --status`) rather than merely answer a version query, so a machine
in that half-installed state is offered the (now complete) installer
instead of a doomed setup. And
when a WSL command fails anyway, the error now
carries WSL's own explanation — WSL prints its errors on the output stream
the app wasn't reading — with a hint to restart Windows, or to enable
virtualization in the machine's BIOS/UEFI settings, when what failed is
WSL 2's virtual machine starting up.

- **Work no longer offers to run a session in a workspace** — the new-session
screen's "Local" chip creates a git worktree on the machine the session runs
on, so in a sandbox it checked out into the sandbox's own disk: invisible
Expand Down
4 changes: 2 additions & 2 deletions DIVERGENCES.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,9 @@ export const dict = {
"sandbox.wsl.downloading": "Downloading WSL... {{done}} MB of {{total}} MB",
"sandbox.wsl.installing": "Installing WSL. Approve the Windows prompt to continue.",
"sandbox.wsl.restartRequired": "WSL is installed. Restart Windows to finish, then set up your sandbox.",
"sandbox.wsl.restartAction": "Restart Windows",
"sandbox.wsl.restartConfirm": "Restart now",
"sandbox.wsl.restartConfirmHint": "Windows will close CoDev and every other open application. Save your work first.",
"sandbox.wsl.failed": "WSL could not be installed: {{message}}",
"sandbox.mountPrompt.body": "Your sandbox is ready. Mount a project to start working in it.",
"sandbox.mountPrompt.action": "Mount a project",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,10 @@ export const dict = {
"sandbox.wsl.downloading": "Descargando WSL... {{done}} MB de {{total}} MB",
"sandbox.wsl.installing": "Instalando WSL. Acepta el aviso de Windows para continuar.",
"sandbox.wsl.restartRequired": "WSL está instalado. Reinicia Windows para terminar y luego configura tu sandbox.",
"sandbox.wsl.restartAction": "Reiniciar Windows",
"sandbox.wsl.restartConfirm": "Reiniciar ahora",
"sandbox.wsl.restartConfirmHint":
"Windows cerrará CoDev y todas las demás aplicaciones abiertas. Guarda tu trabajo primero.",
"sandbox.wsl.failed": "No se pudo instalar WSL: {{message}}",
"sandbox.mountPrompt.body": "Tu sandbox está listo. Monta un proyecto para empezar a trabajar en él.",
"sandbox.mountPrompt.action": "Montar un proyecto",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/sandbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ export type SandboxesPlatform = {
// (needs a restart, failed) are not exceptions.
installWsl(): Promise<void>
cancelWslInstall(): Promise<void>
// Restarts Windows, for an install that ended in restart-required. The
// caller confirms with the user first: this closes every open application.
restartWindows(): Promise<void>
wslInstallState(): Promise<WslInstallState>
subscribeWslInstall(cb: (state: WslInstallState) => void): () => void
// Resolves undefined when the create was cancelled mid-download.
Expand Down
44 changes: 41 additions & 3 deletions packages/app/src/sandbox/wsl-install.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { showToast } from "@opencode-ai/ui/toast"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
import { Match, Show, Switch, createEffect } from "solid-js"
import { Match, Show, Switch, createEffect, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSandboxes } from "./context"
Expand Down Expand Up @@ -54,6 +54,22 @@ export function SandboxWslInstall(props: { message: string }) {
})
}

// Restarting closes every open application, so the button arms a confirm
// step instead of firing; a refusal (shutdown policy, a pending shutdown)
// comes back as a rejection and disarms it.
const [confirmRestart, setConfirmRestart] = createSignal(false)
const restart = () => {
if (!api) return
api.restartWindows().catch((error: unknown) => {
setConfirmRestart(false)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
})
}

return (
<div class="flex flex-col gap-3">
<p class="text-14 text-v2-text-text-muted">{props.message}</p>
Expand Down Expand Up @@ -85,8 +101,8 @@ export function SandboxWslInstall(props: { message: string }) {
<Match when={state().status === "installing"}>
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.wsl.installing")}</p>
</Match>
{/* Terminal: nothing this app can do next, and running the installer
again would not change it. */}
{/* The install is done; only Windows restarting finishes it, so the
one thing left to offer is doing that. */}
<Match when={state().status === "restart-required"}>
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.wsl.restartRequired")}</p>
</Match>
Expand All @@ -96,6 +112,28 @@ export function SandboxWslInstall(props: { message: string }) {
)}
</Match>
</Switch>
<Show when={state().status === "restart-required"}>
<Show
when={confirmRestart()}
fallback={
<div class="flex items-center gap-2">
<ButtonV2 variant="contrast" onClick={() => setConfirmRestart(true)}>
{language.t("sandbox.wsl.restartAction")}
</ButtonV2>
</div>
}
>
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.wsl.restartConfirmHint")}</p>
<div class="flex items-center gap-2">
<ButtonV2 variant="danger" onClick={restart}>
{language.t("sandbox.wsl.restartConfirm")}
</ButtonV2>
<ButtonV2 variant="neutral" onClick={() => setConfirmRestart(false)}>
{language.t("common.cancel")}
</ButtonV2>
</div>
</Show>
</Show>
<Show when={state().status !== "restart-required"}>
<div class="flex items-center gap-2">
<ButtonV2 variant={busy() ? "loading" : "contrast"} disabled={busy()} onClick={install}>
Expand Down
10 changes: 10 additions & 0 deletions packages/codev-sandbox/src/wsl/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ export function runWsl(
})
}

// wsl.exe reports failures on stdout, not stderr — localized text, usually
// UTF-16LE, which decodeWslOutput has already normalized by the time a result
// reaches this. An error message built from stderr alone therefore reduces a
// real diagnostic ("Error code: Wsl/Service/RegisterDistro/CreateVm/HCS/…")
// to a bare "exited 4294967295".
export function wslFailureDetail(result: WslResult) {
const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n")
return detail.length ? detail : `exited ${result.code}`
}

// `wsl --list --quiet` prints one distro per line; entries can carry a trailing
// CR and the default distro is not marked in --quiet mode.
export function parseDistroList(stdout: string) {
Expand Down
67 changes: 62 additions & 5 deletions packages/codev-sandbox/src/wsl/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { SandboxConfig } from "../provider"
import { decodeWslOutput, parseDistroList, wslEnvSpec } from "./cli"
import { decodeWslOutput, parseDistroList, wslEnvSpec, wslFailureDetail } from "./cli"
import { createWslProvider, wslDistroName, type WslProviderOptions } from "./provider"

// tmpdir() rather than a literal /tmp so the suite also runs on the Windows
Expand All @@ -25,6 +25,7 @@ const args = process.argv.slice(2)
const out = (text) => process.stdout.write(Buffer.from(text, "utf16le"))

if (args[0] === "--version") { out("WSL version: 2.3.24.0\\r\\n"); process.exit(0) }
if (args[0] === "--status") { out("Default Version: 2\\r\\n"); process.exit(0) }
if (args[0] === "--list") { out(read().distros.join("\\r\\n") + "\\r\\n"); process.exit(0) }
if (args[0] === "--import") {
const value = read(); value.distros.push(args[1]); value.imports = (value.imports ?? 0) + 1; write(value)
Expand Down Expand Up @@ -107,6 +108,16 @@ test("wslEnvSpec marks every variable host-to-guest only", () => {
expect(wslEnvSpec(["CODEV_SERVER_PORT", "HOME"])).toBe("CODEV_SERVER_PORT/u:HOME/u")
})

// wsl.exe reports failures on stdout, not stderr; a message built from stderr
// alone reduced a real diagnostic to a bare "exited 4294967295".
test("wslFailureDetail keeps the stdout diagnostic and falls back to the exit code", () => {
expect(wslFailureDetail({ code: 1, stdout: "", stderr: " bad \r\n" })).toBe("bad")
expect(wslFailureDetail({ code: null, stdout: "Error code: Wsl/Service/CreateVm\r\n", stderr: "" })).toBe(
"Error code: Wsl/Service/CreateVm",
)
expect(wslFailureDetail({ code: 4294967295, stdout: "", stderr: "" })).toBe("exited 4294967295")
})

test("availability refuses non-Windows hosts", async () => {
const dir = await mkdtemp(join(tmpdir(), "wsl-test-"))
const provider = createWslProvider(await preparedOptions(dir, { platform: "darwin" }))
Expand Down Expand Up @@ -179,11 +190,34 @@ test("availability survives an unreachable installer manifest", async () => {
await rm(dir, { recursive: true, force: true })
})

test("availability reports unsupported when wsl --version fails", async () => {
// The machine that shaped the --status probe: the MSI is installed, so
// --version passes, but Windows 10 without the WSL optional component cannot
// run a distro — and --status names the missing piece.
test("availability surfaces why an installed WSL cannot run", async () => {
const dir = await mkdtemp(join(tmpdir(), "wsl-test-"))
const fake = join(dir, "fake-wsl-status.mjs")
await writeFile(
fake,
`const out = (text) => process.stdout.write(Buffer.from(text, "utf16le"))
if (process.argv[2] === "--version") { out("WSL version: 2.3.24.0\\r\\n"); process.exit(0) }
out("This application requires the Windows Subsystem for Linux Optional Component.\\r\\nError code: Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED\\r\\n")
process.exit(1)
`,
)
const provider = createWslProvider(await preparedOptions(dir, { wslCommand: fake }))
const availability = await provider.availability()
expect(availability.supported).toBe(false)
if (!availability.supported) expect(availability.message).toContain("WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED")
await rm(dir, { recursive: true, force: true })
})

// wsl.exe missing entirely (spawn fails): the one case with no wsl.exe output
// to show, so the generic install prompt is the message.
test("availability reports unsupported when wsl.exe is not there at all", async () => {
const dir = await mkdtemp(join(tmpdir(), "wsl-test-"))
const missing = join(dir, "missing-wsl.mjs")
await writeFile(missing, `process.exit(1)`)
const provider = createWslProvider(await preparedOptions(dir, { wslCommand: missing }))
const provider = createWslProvider(
await preparedOptions(dir, { wslCommand: join(dir, "missing-wsl.exe"), spawnImpl: spawn }),
)
const availability = await provider.availability()
expect(availability.supported).toBe(false)
expect(availability.supported === false && availability.message).toContain("WSL 2 is not installed")
Expand All @@ -208,6 +242,29 @@ test("create imports the distro once and is idempotent", async () => {
await rm(dir, { recursive: true, force: true })
})

// The failure a machine that installed WSL 2 but has not restarted shows:
// --import needs the WSL2 utility VM, wsl.exe puts the diagnostic on stdout in
// UTF-16LE, stderr stays empty, and the exit code is -1.
test("an import failure surfaces wsl.exe's stdout diagnostic and the restart hint", async () => {
const dir = await mkdtemp(join(tmpdir(), "wsl-test-"))
const fake = join(dir, "fake-wsl-no-vm.mjs")
await writeFile(
fake,
`const out = (text) => process.stdout.write(Buffer.from(text, "utf16le"))
const args = process.argv.slice(2)
if (args[0] === "--list") { out("\\r\\n"); process.exit(0) }
if (args[0] === "--import") {
out("Error code: Wsl/Service/RegisterDistro/CreateVm/HCS/HCS_E_HYPERV_NOT_INSTALLED\\r\\n")
process.exit(255)
}
process.exit(1)
`,
)
const provider = createWslProvider(await preparedOptions(dir, { wslCommand: fake }))
await expect(provider.create(config)).rejects.toThrow(/HCS_E_HYPERV_NOT_INSTALLED[^]*restart Windows/)
await rm(dir, { recursive: true, force: true })
})

test("create rebuilds a registered distro when the image moved on", async () => {
const dir = await mkdtemp(join(tmpdir(), "wsl-test-"))
const provider = createWslProvider(await preparedOptions(dir))
Expand Down
50 changes: 34 additions & 16 deletions packages/codev-sandbox/src/wsl/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ import type {
SandboxJobOptions,
SandboxProvider,
} from "../provider"
import { parseDistroList, reserveLoopbackPort, runWsl, wslEnvSpec } from "./cli"
import { parseDistroList, reserveLoopbackPort, runWsl, wslEnvSpec, wslFailureDetail } from "./cli"

// The HCS error family wsl.exe raises when the WSL2 utility VM cannot start:
// VirtualMachinePlatform enabled but inactive until Windows restarts, or
// virtualization switched off in firmware. 0x80370102 is the numeric form of
// HCS_E_HYPERV_NOT_INSTALLED on builds that print no symbolic name.
const WSL_VM_UNAVAILABLE = /0x80370102|0x80370114|HCS_E_HYPERV_NOT_INSTALLED|HCS_E_SERVICE_NOT_AVAILABLE/i
const WSL_VM_UNAVAILABLE_HINT =
"WSL 2 could not start its virtual machine. If WSL was just installed, restart Windows and try again; if it still fails, enable virtualization in the machine's BIOS/UEFI settings."

// Guest-side paths. The bundle mount point and the launcher are baked into the
// image, so they match the vz and bwrap backends exactly.
Expand Down Expand Up @@ -93,7 +101,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider
const translated = await wsl(["-d", distro, "--exec", "wslpath", "-a", "-u", windowsPath])
const path = translated.stdout.trim()
if (translated.code !== 0 || !path.length)
throw new Error(`could not translate ${windowsPath} for the sandbox: ${translated.stderr.trim()}`)
throw new Error(`could not translate ${windowsPath} for the sandbox: ${wslFailureDetail(translated)}`)
return path
}

Expand All @@ -105,9 +113,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider
await wsl(["--terminate", distro])
const unregistered = await wsl(["--unregister", distro], { timeoutMs: WSL_UNREGISTER_TIMEOUT_MS })
if (unregistered.code !== 0)
throw new Error(
`wsl --unregister failed for ${distro}: ${unregistered.stderr.trim() || `exited ${unregistered.code}`}`,
)
throw new Error(`wsl --unregister failed for ${distro}: ${wslFailureDetail(unregistered)}`)
}
await rm(wslSandboxDirs(options.dataDir, config.name).base, { recursive: true, force: true })
}
Expand All @@ -127,9 +133,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider
await wsl(["--terminate", distro])
const unregistered = await wsl(["--unregister", distro], { timeoutMs: WSL_UNREGISTER_TIMEOUT_MS })
if (unregistered.code !== 0)
throw new Error(
`wsl --unregister failed for ${distro}: ${unregistered.stderr.trim() || `exited ${unregistered.code}`}`,
)
throw new Error(`wsl --unregister failed for ${distro}: ${wslFailureDetail(unregistered)}`)
await rm(dirs.install, { recursive: true, force: true })
}
const tarball = await stageAsset(options.cacheDir, asset, {
Expand All @@ -141,8 +145,13 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider
const imported = await wsl(["--import", distro, dirs.install, tarball, "--version", "2"], {
timeoutMs: WSL_IMPORT_TIMEOUT_MS,
})
if (imported.code !== 0)
throw new Error(`wsl --import failed for ${distro}: ${imported.stderr.trim() || `exited ${imported.code}`}`)
if (imported.code !== 0) {
// Import is the first command that needs the WSL2 utility VM, so a
// machine that installed WSL but has not restarted yet fails here.
const detail = wslFailureDetail(imported)
const hint = WSL_VM_UNAVAILABLE.test(detail) ? ` ${WSL_VM_UNAVAILABLE_HINT}` : ""
throw new Error(`wsl --import failed for ${distro}: ${detail}${hint}`)
}
// Windows has no uid to match, so the guest keeps its own codev user and
// the Windows drives are automounted as that user — without this the
// unprivileged server could not write the project directory. wsl.conf is
Expand All @@ -158,9 +167,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider
`printf '[automount]\\noptions = "metadata,uid=1000,gid=1000"\\n' > /etc/wsl.conf`,
])
if (configured.code !== 0)
throw new Error(
`could not write /etc/wsl.conf in ${distro}: ${configured.stderr.trim() || `exited ${configured.code}`}`,
)
throw new Error(`could not write /etc/wsl.conf in ${distro}: ${wslFailureDetail(configured)}`)
await wsl(["--terminate", distro])
await writeImageMarker(dirs.base, asset)
}
Expand All @@ -170,12 +177,23 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider

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