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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ 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. 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. 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
on, so in a sandbox it checked out into the sandbox's own disk: invisible
Expand Down
3 changes: 2 additions & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,14 @@ 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.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",
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@ 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.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",
Expand Down
29 changes: 28 additions & 1 deletion packages/app/src/sandbox/context.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -32,6 +33,16 @@ export const { use: useSandboxes, provider: SandboxesProvider } = createSimpleCo
gcTime: Number.POSITIVE_INFINITY,
})
})
const wslInstall = useQuery(() => {
const api = platform.sandboxes
return queryOptions<WslInstallState>({
queryKey: wslInstallQueryKey,
queryFn: () => api!.wslInstallState(),
enabled: !!api,
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
})
})

createEffect(() => {
const api = platform.sandboxes
Expand All @@ -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 }),
}
},
})
3 changes: 2 additions & 1 deletion packages/app/src/sandbox/dialog-add-default.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -98,7 +99,7 @@ export function DialogAddDefaultSandbox(props: { onCreated?: (config: SandboxCon
<DialogBody class="flex flex-col gap-3 px-4 pt-3 pb-1">
<Show
when={availability()?.supported !== false}
fallback={<p class="text-14 text-v2-text-text-muted">{unsupportedMessage()}</p>}
fallback={<SandboxWslInstall message={unsupportedMessage() ?? ""} />}
>
{/* warn-never-block: the warning is shown, creation stays enabled */}
<Show when={warning()}>{(text) => <p class="text-12 text-v2-text-text-muted">{text()}</p>}</Show>
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/sandbox/dialog-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -167,7 +168,7 @@ export function DialogAddSandbox(props: {
<DialogBody class="flex flex-col gap-3 px-4 pt-3 pb-1">
<Show
when={availability()?.supported !== false}
fallback={<p class="text-14 text-v2-text-text-muted">{unsupportedMessage()}</p>}
fallback={<SandboxWslInstall message={unsupportedMessage() ?? ""} />}
>
{/* warn-never-block: the warning is shown, creation stays enabled */}
<Show when={warning()}>{(text) => <p class="text-12 text-v2-text-text-muted">{text()}</p>}</Show>
Expand Down
23 changes: 23 additions & 0 deletions packages/app/src/sandbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SandboxesState>
subscribe(cb: (event: SandboxesEvent) => void): () => void
availability(): Promise<SandboxAvailability>
// 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<void>
cancelWslInstall(): Promise<void>
wslInstallState(): Promise<WslInstallState>
subscribeWslInstall(cb: (state: WslInstallState) => void): () => void
// Resolves undefined when the create was cancelled mid-download.
add(
name: string,
Expand Down
124 changes: 124 additions & 0 deletions packages/app/src/sandbox/wsl-install.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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 (
<div class="flex flex-col gap-3">
<p class="text-14 text-v2-text-text-muted">{props.message}</p>
<Show when={offered()}>
<Switch>
<Match when={downloading()}>
{(current) => (
<div class="flex flex-col gap-1.5">
<div class="h-1 w-full overflow-hidden rounded-full bg-v2-background-bg-layer-04">
<div
class="h-full rounded-full bg-v2-icon-icon-base transition-[width] duration-200"
style={{ width: `${Math.min(100, (current().bytes / current().totalBytes) * 100)}%` }}
/>
</div>
{/* The size comes from the manifest, so it is known before a
byte arrives — there is no indeterminate case to cover. */}
<p class="text-12 text-v2-text-text-muted">
{language.t("sandbox.wsl.downloading", {
done: Math.round(current().bytes / 1024 / 1024),
total: Math.round(current().totalBytes / 1024 / 1024),
})}
</p>
</div>
)}
</Match>
{/* 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. */}
<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. */}
<Match when={state().status === "restart-required"}>
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.wsl.restartRequired")}</p>
</Match>
<Match when={failure()}>
{(message) => (
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.wsl.failed", { message: message() })}</p>
)}
</Match>
</Switch>
<Show when={state().status !== "restart-required"}>
<div class="flex items-center gap-2">
<ButtonV2 variant={busy() ? "loading" : "contrast"} disabled={busy()} onClick={install}>
<Show
when={busy()}
fallback={language.t(failure() ? "sandbox.wsl.installRetry" : "sandbox.wsl.installAction")}
>
<LoaderV2 />
</Show>
</ButtonV2>
{/* Only the download is worth stopping; the elevated step belongs
to msiexec and Windows offers nothing to cancel. */}
<Show when={downloading()}>
<ButtonV2 variant="neutral" onClick={() => void api?.cancelWslInstall()}>
{language.t("common.cancel")}
</ButtonV2>
</Show>
</div>
<Show when={!busy()}>
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.wsl.installHint")}</p>
</Show>
</Show>
</Show>
</div>
)
}
83 changes: 83 additions & 0 deletions packages/codev-sandbox/script/publish-wsl-installer.ts
Original file line number Diff line number Diff line change
@@ -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.<version>.0.x64.msi, wsl.<version>.0.arm64.msi
// from https://github.com/microsoft/WSL/releases/tag/<version>
//
// 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<string, { url: string; sha256: string; bytes: number }> = {}
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. 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`)
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} <alias>/<bucket>/${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
}
Loading
Loading