diff --git a/packages/codev-sandbox/script/build-image.ts b/packages/codev-sandbox/script/build-image.ts index f0865bb368..b3682f4f7a 100644 --- a/packages/codev-sandbox/script/build-image.ts +++ b/packages/codev-sandbox/script/build-image.ts @@ -15,6 +15,12 @@ // baked in (other parts are fetched // arch-correct during the build) // [--no-office] base toolchain only +// [--backend bwrap] [--backend vz] default: both. bwrap alone skips the +// kernel and ext4 steps, which is what +// makes a cross-arch x64 build +// practical: WSL and bubblewrap consume +// the rootfs tarball and supply their +// own kernel, so only vz needs one. // [--skip-kernel] reuse an existing vmlinux- // [--out dir] default: dist-images/ import { $ } from "bun" @@ -25,7 +31,7 @@ import { join } from "node:path" import { fileURLToPath } from "node:url" import { SANDBOX_IMAGES_BASE_URL } from "../src/const" import { parseSandboxManifest, type SandboxAsset } from "../src/manifest" -import type { SandboxArch } from "../src/provider" +import type { SandboxArch, SandboxBackend } from "../src/provider" const imageDir = fileURLToPath(new URL("../image/", import.meta.url)) const DISK_SIZE = "8G" @@ -36,6 +42,7 @@ if (!version) { process.exit(1) } const archs = collectArchs() +const backends = collectBackends() const officeBundle = argValue("--office-bundle") const office = process.argv.includes("--no-office") ? "0" : "1" const skipKernel = process.argv.includes("--skip-kernel") @@ -69,50 +76,54 @@ for (const arch of archs) { () => $`docker buildx build --platform linux/${docker} -t codev-sandbox-tools:${arch} --load ${join(imageDir, "tools")}`.nothrow(), ) + // The rootfs tarball feeds both backends: bwrap and wsl --import ship it + // as-is, and the vz ext4 disk is built from it. console.log("exporting rootfs tarball...") const cid = (await $`docker create --platform linux/${docker} codev-sandbox-guest:${arch}`.text()).trim() const bwrapTar = join(outDir, `bwrap-${arch}.tar`) await $`docker export ${cid} -o ${bwrapTar}` await $`docker rm ${cid}`.quiet() await $`docker run --rm -v ${outDir}:/out codev-sandbox-tools:${arch} gzip -f -6 /out/bwrap-${arch}.tar` - assets[`bwrap-${arch}`] = await describeAsset(join(outDir, `bwrap-${arch}.tar.gz`)) - - console.log("building ext4 disk...") - await $`docker run --rm -v ${outDir}:/out codev-sandbox-tools:${arch} sh -ec ${[ - `mkdir /r`, - `tar -xzf /out/bwrap-${arch}.tar.gz -C /r`, - `truncate -s ${DISK_SIZE} /out/rootfs-${arch}.img`, - `mkfs.ext4 -F -q -d /r /out/rootfs-${arch}.img`, - ].join(" && ")}` - - const kernel = join(outDir, `vmlinux-${arch}`) - if (!skipKernel) { - console.log("building kernel (cached after the first run)...") - await retry( - "kernel build", - () => - $`docker buildx build --platform linux/${docker} --target build -t codev-sandbox-kernel:${arch} --load ${join(imageDir, "kernel")}`.nothrow(), - ) - const kernelCid = (await $`docker create --platform linux/${docker} codev-sandbox-kernel:${arch} true`.text()).trim() - await $`docker cp ${kernelCid}:/out/vmlinux ${kernel}` - await $`docker rm ${kernelCid}`.quiet() - } - if (!(await stat(kernel).catch(() => null))) { - console.error(`missing ${kernel}; run without --skip-kernel`) - process.exit(1) - } + if (backends.includes("bwrap")) assets[`bwrap-${arch}`] = await describeAsset(join(outDir, `bwrap-${arch}.tar.gz`)) + + if (backends.includes("vz")) { + console.log("building ext4 disk...") + await $`docker run --rm -v ${outDir}:/out codev-sandbox-tools:${arch} sh -ec ${[ + `mkdir /r`, + `tar -xzf /out/bwrap-${arch}.tar.gz -C /r`, + `truncate -s ${DISK_SIZE} /out/rootfs-${arch}.img`, + `mkfs.ext4 -F -q -d /r /out/rootfs-${arch}.img`, + ].join(" && ")}` + + const kernel = join(outDir, `vmlinux-${arch}`) + if (!skipKernel) { + console.log("building kernel (cached after the first run)...") + await retry( + "kernel build", + () => + $`docker buildx build --platform linux/${docker} --target build -t codev-sandbox-kernel:${arch} --load ${join(imageDir, "kernel")}`.nothrow(), + ) + const kernelCid = (await $`docker create --platform linux/${docker} codev-sandbox-kernel:${arch} true`.text()).trim() + await $`docker cp ${kernelCid}:/out/vmlinux ${kernel}` + await $`docker rm ${kernelCid}`.quiet() + } + if (!(await stat(kernel).catch(() => null))) { + console.error(`missing ${kernel}; run without --skip-kernel`) + process.exit(1) + } - console.log("assembling vz tarball...") - const vzStage = join(outDir, `vz-${arch}`) - await rm(vzStage, { recursive: true, force: true }) - await mkdir(vzStage, { recursive: true }) - await cp(kernel, join(vzStage, "vmlinux")) - await $`docker run --rm -v ${outDir}:/out codev-sandbox-tools:${arch} sh -ec ${[ - `mv /out/rootfs-${arch}.img /out/vz-${arch}/rootfs.img`, - `tar -Sczf /out/vz-${arch}.tar.gz -C /out/vz-${arch} vmlinux rootfs.img`, - ].join(" && ")}` - await rm(vzStage, { recursive: true, force: true }) - assets[`vz-${arch}`] = await describeAsset(join(outDir, `vz-${arch}.tar.gz`)) + console.log("assembling vz tarball...") + const vzStage = join(outDir, `vz-${arch}`) + await rm(vzStage, { recursive: true, force: true }) + await mkdir(vzStage, { recursive: true }) + await cp(kernel, join(vzStage, "vmlinux")) + await $`docker run --rm -v ${outDir}:/out codev-sandbox-tools:${arch} sh -ec ${[ + `mv /out/rootfs-${arch}.img /out/vz-${arch}/rootfs.img`, + `tar -Sczf /out/vz-${arch}.tar.gz -C /out/vz-${arch} vmlinux rootfs.img`, + ].join(" && ")}` + await rm(vzStage, { recursive: true, force: true }) + assets[`vz-${arch}`] = await describeAsset(join(outDir, `vz-${arch}.tar.gz`)) + } } const manifest = parseSandboxManifest({ @@ -149,6 +160,17 @@ function argValue(flag: string) { return index >= 0 ? process.argv[index + 1] : undefined } +function collectBackends(): SandboxBackend[] { + const flags = process.argv.flatMap((value, index) => (value === "--backend" ? [process.argv[index + 1]] : [])) + if (!flags.length) return ["bwrap", "vz"] + const selected = flags.filter((value): value is SandboxBackend => value === "bwrap" || value === "vz") + if (selected.length !== flags.length) { + console.error("--backend accepts bwrap or vz") + process.exit(1) + } + return selected +} + function collectArchs(): SandboxArch[] { const flags = process.argv.flatMap((value, index) => (value === "--arch" ? [process.argv[index + 1]] : [])) if (flags.length) return flags.filter((value): value is SandboxArch => value === "arm64" || value === "x64") diff --git a/packages/codev-sandbox/script/e2e-wsl.ts b/packages/codev-sandbox/script/e2e-wsl.ts new file mode 100644 index 0000000000..18b77985b2 --- /dev/null +++ b/packages/codev-sandbox/script/e2e-wsl.ts @@ -0,0 +1,109 @@ +#!/usr/bin/env bun +// Boots a real sandbox end-to-end on Windows: serves a locally built rootfs +// tarball over loopback HTTP, creates the distribution (download -> verify -> +// wsl --import), starts the guest server, and waits for the codev server's +// authenticated health check over WSL2's localhost forwarding. Dev utility. +// +// Run from packages/codev-sandbox on the Windows machine: +// bun script/e2e-wsl.ts --image dist-images\bwrap-x64.tar.gz ^ +// --bundle ..\opencode\dist\node [--project dir] [--hold] [--keep] +// +// --hold keeps the distribution running so you can poke at it; --keep skips the +// unregister so it survives the run (clean up with `wsl --unregister codev-e2e`). +import { createHash } from "node:crypto" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { basename, join, resolve } from "node:path" +import { checkSandboxHealth } from "../src/boot" +import type { SandboxConfig } from "../src/provider" +import { createWslProvider } from "../src/wsl/provider" + +// Both paths cross into the distribution through wslpath, which resolves +// relative paths against the guest's cwd — so they have to be absolute here. +const image = resolve(requireArg("--image")) +const bundle = resolve(requireArg("--bundle")) +const hold = process.argv.includes("--hold") +const keep = process.argv.includes("--keep") + +const work = await mkdtemp(join(tmpdir(), "codev-wsl-e2e-")) +const projectDir = argValue("--project") ? resolve(argValue("--project")!) : join(work, "project") +await mkdir(projectDir, { recursive: true }) +await writeFile(join(projectDir, "hello.txt"), "hello from the host project\n") + +// Digest once, outside resolveImage: a hash object cannot be digested twice, so +// computing it inside the callback breaks the moment the provider asks again. +const payload = Bun.file(image) +const hash = createHash("sha256") +for await (const chunk of payload.stream()) hash.update(chunk) +const sha256 = hash.digest("hex") + +const server = Bun.serve({ port: 0, fetch: () => new Response(Bun.file(image)) }) + +const provider = createWslProvider({ + dataDir: join(work, "data"), + cacheDir: join(work, "cache"), + bundleDir: bundle, + resolveImage: async () => ({ + url: `http://127.0.0.1:${server.port}/${basename(image)}`, + sha256, + bytes: payload.size, + }), + startTimeoutMs: 180_000, +}) + +const availability = await provider.availability() +console.log("availability:", availability) +if (!availability.supported) { + console.error(`cannot run here: ${availability.message}`) + await cleanup() + process.exit(1) +} +if (availability.warning) console.log(`warning: ${availability.warning}`) + +const config: SandboxConfig = { id: "sandbox:e2e", name: "e2e", backend: "wsl", projectDir } + +console.time("create") +await provider.create(config) +console.timeEnd("create") + +console.time("start") +const sandbox = await provider.start(config) +console.timeEnd("start") +console.log(`server: ${sandbox.url} (user ${sandbox.username})`) + +const healthy = await checkSandboxHealth(sandbox.url, { username: sandbox.username ?? "", password: sandbox.password }) +console.log(`health: ${healthy ? "OK" : "FAILED"}`) + +if (hold) { + console.log("holding the distribution up; Ctrl-C to stop") + await new Promise(() => {}) +} + +const exited = new Promise((resolve) => sandbox.onExit(() => resolve())) +sandbox.stop() +await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 10_000))]) +if (keep) console.log(`keeping the distribution; remove it with: wsl --unregister codev-${config.name}`) +if (!keep) await provider.destroy(config) +await cleanup() +if (!healthy) process.exit(1) +console.log("e2e OK") +process.exit(0) + +async function cleanup() { + server.stop(true) + await rm(work, { recursive: true, force: true }) +} + +function argValue(flag: string) { + const index = process.argv.indexOf(flag) + return index >= 0 ? process.argv[index + 1] : undefined +} + +function requireArg(flag: string) { + const value = argValue(flag) + if (!value) { + console.error(`${flag} is required`) + process.exit(1) + } + return value +} diff --git a/packages/codev-sandbox/src/index.ts b/packages/codev-sandbox/src/index.ts index 150f6ac108..c702ed3146 100644 --- a/packages/codev-sandbox/src/index.ts +++ b/packages/codev-sandbox/src/index.ts @@ -12,3 +12,5 @@ export * from "./bwrap/rootfs" export * from "./bwrap/provider" export * from "./vz/protocol" export * from "./vz/provider" +export * from "./wsl/cli" +export * from "./wsl/provider" diff --git a/packages/codev-sandbox/src/wsl/cli.ts b/packages/codev-sandbox/src/wsl/cli.ts new file mode 100644 index 0000000000..9868ab7441 --- /dev/null +++ b/packages/codev-sandbox/src/wsl/cli.ts @@ -0,0 +1,77 @@ +import { spawn } from "node:child_process" +import { createServer } from "node:net" + +export type WslResult = { + code: number | null + stdout: string + stderr: string +} + +// wsl.exe writes its informational output (--version, --status, --list) as +// UTF-16LE with no BOM on most builds, while command output piped from inside a +// distro is plain UTF-8. Sniffing NUL bytes distinguishes them: a UTF-16LE run +// of ASCII always interleaves NULs, which valid UTF-8 text never contains. +export function decodeWslOutput(buffer: Buffer) { + if (!buffer.includes(0)) return buffer.toString("utf8") + return buffer.toString("utf16le").replace(/\0/g, "") +} + +export function runWsl( + args: string[], + opts?: { wslCommand?: string; env?: Record; spawnImpl?: typeof spawn }, +): Promise { + const spawnImpl = opts?.spawnImpl ?? spawn + const child = spawnImpl(opts?.wslCommand ?? "wsl.exe", args, { + stdio: ["ignore", "pipe", "pipe"], + env: opts?.env ? { ...process.env, ...opts.env } : process.env, + }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk)) + child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk)) + return new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", (code) => + resolve({ + code, + stdout: decodeWslOutput(Buffer.concat(stdout)), + stderr: decodeWslOutput(Buffer.concat(stderr)), + }), + ) + }) +} + +// `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) { + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +// WSLENV is the only way to hand a variable to the distro without putting it on +// the command line, where another user on the host could read it out of the +// process table. The /u suffix means "host to WSL only". +export function wslEnvSpec(names: string[]) { + return names.map((name) => `${name}/u`).join(":") +} + +// The server binds inside the distro and WSL2's localhost forwarding republishes +// the same port on the Windows loopback, so host and guest must agree on one +// number. Reserving it on the host first is the only way to avoid collisions +// between concurrent sandboxes. +export function reserveLoopbackPort() { + return new Promise((resolve, reject) => { + const server = createServer() + server.once("error", reject) + server.listen({ host: "127.0.0.1", port: 0 }, () => { + const address = server.address() + if (address === null || typeof address === "string") { + server.close(() => reject(new Error("could not reserve a loopback port"))) + return + } + server.close(() => resolve(address.port)) + }) + }) +} diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts new file mode 100644 index 0000000000..ad679ff895 --- /dev/null +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from "bun:test" +import { spawn } from "node:child_process" +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 { createWslProvider, wslDistroName, type WslProviderOptions } from "./provider" + +const config: SandboxConfig = { id: "sandbox:default", name: "default", backend: "wsl", projectDir: "/tmp" } +const ASSET_SHA = "a".repeat(64) + +// A fake wsl.exe: tracks registered distros in a state file, answers wslpath, +// and for the boot command serves the health endpoint on the port the provider +// handed it through the environment instead of booting Linux. Output is UTF-16LE +// for the informational subcommands, matching the real wsl.exe. +const fakeWsl = (statePath: string) => `import { createServer } from "node:net" +import { readFileSync, writeFileSync } from "node:fs" +const state = ${JSON.stringify(statePath)} +const read = () => { try { return JSON.parse(readFileSync(state, "utf8")) } catch { return { distros: [] } } } +const write = (value) => writeFileSync(state, JSON.stringify(value)) +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] === "--list") { out(read().distros.join("\\r\\n") + "\\r\\n"); process.exit(0) } +if (args[0] === "--import") { + const value = read(); value.distros.push(args[1]); write(value) + out("The operation completed successfully.\\r\\n"); process.exit(0) +} +if (args[0] === "--terminate") { process.exit(0) } +if (args[0] === "--unregister") { + const value = read(); value.distros = value.distros.filter((d) => d !== args[1]); write(value) + process.exit(0) +} +if (args.includes("wslpath")) { process.stdout.write("/mnt/hostpath\\n"); process.exit(0) } + +// Boot command: assert the script the provider built, then answer health checks. +const script = args[args.length - 1] +if (!script.includes("mount --bind") || !script.includes("server-launch.mjs")) { + process.stderr.write("unexpected boot script\\n"); process.exit(2) +} +const listener = createServer((socket) => { + socket.on("data", () => socket.end("HTTP/1.1 200 OK\\r\\ncontent-length: 2\\r\\nconnection: close\\r\\n\\r\\nok")) +}) +listener.listen(Number(process.env.CODEV_SERVER_PORT), "127.0.0.1") +setInterval(() => {}, 1000) +` + +// The fakes are .mjs scripts, so route them through the current runtime the way +// the vz tests route their fake helper. +const runViaExec = ((command: string, args: string[], opts: unknown) => + spawn(process.execPath, [command, ...args], opts as never)) as unknown as typeof spawn + +async function preparedOptions(dir: string, overrides?: Partial) { + const fake = join(dir, "fake-wsl.mjs") + await writeFile(fake, fakeWsl(join(dir, "state.json"))) + // Pre-stage the asset so stageAsset short-circuits on its digest/size check + // and the test never reaches the network. + const cacheDir = join(dir, "cache") + await mkdir(join(cacheDir, ASSET_SHA), { recursive: true }) + await writeFile(join(cacheDir, ASSET_SHA, "rootfs.tar.gz"), "x") + return { + dataDir: join(dir, "data"), + cacheDir, + bundleDir: "C:\\bundle\\dist\\node", + resolveImage: async () => ({ + url: "https://example.invalid/sandbox/rootfs.tar.gz", + sha256: ASSET_SHA, + bytes: 1, + }), + wslCommand: fake, + platform: "win32" as NodeJS.Platform, + osRelease: () => "10.0.22631", + spawnImpl: runViaExec, + startTimeoutMs: 20_000, + ...overrides, + } satisfies WslProviderOptions +} + +test("decodeWslOutput handles wsl.exe UTF-16LE and plain UTF-8", () => { + expect(decodeWslOutput(Buffer.from("codev-default\r\n", "utf16le"))).toBe("codev-default\r\n") + expect(decodeWslOutput(Buffer.from("/mnt/c/project\n", "utf8"))).toBe("/mnt/c/project\n") +}) + +test("parseDistroList drops blank lines and trailing CR", () => { + expect(parseDistroList("codev-a\r\ncodev-b\r\n\r\n")).toEqual(["codev-a", "codev-b"]) +}) + +test("wslDistroName keeps names safe for wsl --list and directories", () => { + expect(wslDistroName("my project #1")).toBe("codev-my-project-1") +}) + +test("wslEnvSpec marks every variable host-to-guest only", () => { + expect(wslEnvSpec(["CODEV_SERVER_PORT", "HOME"])).toBe("CODEV_SERVER_PORT/u:HOME/u") +}) + +test("availability refuses non-Windows hosts", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider(await preparedOptions(dir, { platform: "darwin" })) + expect(await provider.availability()).toEqual({ supported: false, message: "WSL sandboxes require Windows" }) + await rm(dir, { recursive: true, force: true }) +}) + +test("availability warns on Windows 10 22H2 but stays supported", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider(await preparedOptions(dir, { osRelease: () => "10.0.19045" })) + const availability = await provider.availability() + expect(availability.supported).toBe(true) + expect(availability.supported && availability.warning).toContain("Windows 10") + await rm(dir, { recursive: true, force: true }) +}) + +test("availability is clean on Windows 11", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider(await preparedOptions(dir)) + expect(await provider.availability()).toEqual({ supported: true, warning: null }) + 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") + await writeFile(missing, `process.exit(1)`) + const provider = createWslProvider(await preparedOptions(dir, { wslCommand: missing })) + const availability = await provider.availability() + expect(availability.supported).toBe(false) + expect(availability.supported === false && availability.message).toContain("WSL 2 is not installed") + await rm(dir, { recursive: true, force: true }) +}) + +test("start refuses a sandbox that was never created", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider(await preparedOptions(dir)) + await expect(provider.start(config)).rejects.toThrow(`no WSL distribution ${wslDistroName(config.name)}`) + await rm(dir, { recursive: true, force: true }) +}) + +test("create imports the distro once and is idempotent", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider(await preparedOptions(dir)) + await provider.create(config) + await provider.create(config) + const state = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + expect(state.distros).toEqual([wslDistroName(config.name)]) + await rm(dir, { recursive: true, force: true }) +}) + +test("create then start reaches an authenticated health check, and destroy unregisters", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider(await preparedOptions(dir)) + await provider.create(config) + const sandbox = await provider.start(config) + expect(sandbox.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) + expect(sandbox.username).toBe("codev") + sandbox.stop() + await provider.destroy(config) + const state = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + expect(state.distros).toEqual([]) + await rm(dir, { recursive: true, force: true }) +}) diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts new file mode 100644 index 0000000000..18403936f8 --- /dev/null +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -0,0 +1,219 @@ +import { spawn } from "node:child_process" +import { mkdir, rm, stat } from "node:fs/promises" +import { release } from "node:os" +import { join } from "node:path" +import { + SANDBOX_GUEST_LAUNCHER, + checkSandboxHealth, + generateSandboxCredentials, + pollSandboxHealth, + sandboxBootEnv, +} from "../boot" +import { stageAsset } from "../image" +import type { SandboxAsset } from "../manifest" +import { windowsBuildNumber, windowsSandboxAvailability } from "../policy" +import type { SandboxAvailability, SandboxConfig, SandboxProvider } from "../provider" +import { parseDistroList, reserveLoopbackPort, runWsl, wslEnvSpec } from "./cli" + +// Guest-side paths. The bundle mount point and the launcher are baked into the +// image, so they match the vz and bwrap backends exactly. +const GUEST_HOME = "/home/codev" +const GUEST_BUNDLE = "/opt/codev" + +export type WslProviderOptions = { + dataDir: string + cacheDir: string + // Windows path of the server bundle (dist/node). Bind-mounted onto + // GUEST_BUNDLE inside the distro so version parity with the desktop app is + // automatic — the image never bakes a server. + bundleDir: string + resolveImage: (opts?: { signal?: AbortSignal }) => Promise + wslCommand?: string + platform?: NodeJS.Platform + osRelease?: () => string + spawnImpl?: typeof spawn + fetchImpl?: typeof fetch + startTimeoutMs?: number +} + +// WSL distribution names appear in `wsl --list` and as directory names, so keep +// them to characters that are safe in both. +export function wslDistroName(name: string) { + return `codev-${name.replace(/[^A-Za-z0-9._-]+/g, "-")}` +} + +export function wslSandboxDirs(dataDir: string, name: string) { + const base = join(dataDir, name) + return { base, install: join(base, "distro") } +} + +export function createWslProvider(options: WslProviderOptions): SandboxProvider { + const platform = options.platform ?? process.platform + const osRelease = options.osRelease ?? release + const spawnImpl = options.spawnImpl ?? spawn + const fetchImpl = options.fetchImpl ?? fetch + const startTimeoutMs = options.startTimeoutMs ?? 120_000 + const wsl = (args: string[], env?: Record) => + runWsl(args, { wslCommand: options.wslCommand, env, spawnImpl }) + + const registered = async (distro: string) => { + const listed = await wsl(["--list", "--quiet"]) + return parseDistroList(listed.stdout).includes(distro) + } + + // Windows paths have to be translated before they can be referenced inside + // the distro; wslpath is the only translation that accounts for the user's + // actual /etc/wsl.conf automount settings. + const guestPath = async (distro: string, windowsPath: string) => { + 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()}`) + return path + } + + // Unregistering is what actually deletes a WSL sandbox's disk; removing the + // install directory only cleans up what --import left behind. + const discard = async (config: SandboxConfig) => { + const distro = wslDistroName(config.name) + if (await registered(distro)) { + await wsl(["--terminate", distro]) + const unregistered = await wsl(["--unregister", distro]) + if (unregistered.code !== 0) + throw new Error( + `wsl --unregister failed for ${distro}: ${unregistered.stderr.trim() || `exited ${unregistered.code}`}`, + ) + } + await rm(wslSandboxDirs(options.dataDir, config.name).base, { recursive: true, force: true }) + } + + const prepare = async (config: SandboxConfig, opts?: { signal?: AbortSignal }) => { + const distro = wslDistroName(config.name) + const asset = await options.resolveImage(opts) + const tarball = await stageAsset(options.cacheDir, asset, { fetchImpl, signal: opts?.signal }) + if (await registered(distro)) return + const dirs = wslSandboxDirs(options.dataDir, config.name) + await mkdir(dirs.install, { recursive: true }) + const imported = await wsl(["--import", distro, dirs.install, tarball, "--version", "2"]) + if (imported.code !== 0) + throw new Error(`wsl --import failed for ${distro}: ${imported.stderr.trim() || `exited ${imported.code}`}`) + } + + return { + backend: "wsl", + + async availability(): Promise { + if (platform !== "win32") return { supported: false, message: "WSL sandboxes require Windows" } + const version = await wsl(["--version"]).catch(() => null) + if (!version || version.code !== 0) + return { + supported: false, + message: "WSL 2 is not installed. Install it to use the sandbox, then try again.", + } + return windowsSandboxAvailability(windowsBuildNumber(osRelease())) + }, + + async create(config, opts) { + await prepare(config, opts) + }, + + async start(config) { + const distro = wslDistroName(config.name) + const project = await stat(config.projectDir).catch(() => null) + if (!project?.isDirectory()) throw new Error(`project directory does not exist: ${config.projectDir}`) + if (!(await registered(distro))) throw new Error(`sandbox has not been created: no WSL distribution ${distro}`) + + const credentials = generateSandboxCredentials() + // Host and guest must agree on one port: WSL2 republishes the guest's + // loopback port under the same number on the Windows loopback. + const port = await reserveLoopbackPort() + const env = { + ...sandboxBootEnv(credentials, GUEST_HOME, { hostname: "127.0.0.1", port }), + HOME: GUEST_HOME, + } + const bootScript = [ + `set -u`, + // The image ships an empty GUEST_BUNDLE; binding the host bundle over it + // keeps the launcher's import path identical across backends. + `mount --bind ${shellQuote(await guestPath(distro, options.bundleDir))} ${GUEST_BUNDLE}`, + `cp -Rn /etc/skel/. "$HOME"/ 2>/dev/null || true`, + `if [ -f /etc/codev-guest.env ]; then . /etc/codev-guest.env; fi`, + `cd ${shellQuote(await guestPath(distro, config.projectDir))}`, + `exec node ${SANDBOX_GUEST_LAUNCHER}`, + ].join("\n") + + const child = spawnImpl( + options.wslCommand ?? "wsl.exe", + ["-d", distro, "-u", "root", "--", "/bin/sh", "-c", bootScript], + { + stdio: ["ignore", "ignore", "pipe"], + // WSLENV forwards these into the distro without exposing the password + // on a command line that any host process could read. + env: { ...process.env, ...env, WSLENV: wslEnvSpec(Object.keys(env)) }, + }, + ) + let stderrTail = "" + child.stderr?.on("data", (chunk) => { + stderrTail = `${stderrTail}${chunk}`.slice(-8000) + }) + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.once("exit", (code, signal) => resolve({ code, signal })) + }) + const url = `http://127.0.0.1:${port}` + + const abort = new AbortController() + let timeoutHandle: ReturnType | undefined + const outcome = await Promise.race([ + pollSandboxHealth(() => checkSandboxHealth(url, credentials, fetchImpl), abort.signal).then( + () => "healthy" as const, + ), + exited, + new Promise<"timeout">((resolve) => { + timeoutHandle = setTimeout(() => resolve("timeout"), startTimeoutMs) + }), + ]) + abort.abort() + clearTimeout(timeoutHandle) + if (outcome !== "healthy") { + child.kill() + void wsl(["--terminate", distro]) + const detail = stderrTail.trim() + if (outcome === "timeout") + throw new Error( + `sandbox server did not become healthy within ${startTimeoutMs}ms${detail ? `: ${detail}` : ""}`, + ) + throw new Error( + `sandbox server exited during startup (code=${outcome.code ?? "null"} signal=${outcome.signal ?? "null"})${detail ? `: ${detail}` : ""}`, + ) + } + + return { + url, + username: credentials.username, + password: credentials.password, + stop: () => { + child.kill() + // Killing wsl.exe ends the command but leaves the distro's VM running; + // terminating it releases the loopback port mapping too. + void wsl(["--terminate", distro]) + }, + onExit: (cb) => { + void exited.then((exit) => cb(exit.code, exit.signal)) + }, + } + }, + + async destroy(config) { + await discard(config) + }, + + async reset(config, opts) { + await discard(config) + await prepare(config, opts) + }, + } +} + +function shellQuote(value: string) { + return `'${value.replace(/'/g, `'\\''`)}'` +}