From b78b883cac15cf8ed22c23d28d98210a906a8e6c Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 12 Aug 2026 11:18:17 +0700 Subject: [PATCH 1/3] feat(sandbox): let the image build target a single backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WSL and bubblewrap consume the rootfs tarball and bring their own kernel; only vz needs the compiled one. Building the two together meant a cross-arch x64 rootfs — what `wsl --import` needs on an x64 Windows fleet — dragged in a QEMU-emulated Linux compile that takes hours on an Apple Silicon host. --backend bwrap now stops after the tarball, skipping the ext4 and kernel stages. Default stays both backends, so existing invocations are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- packages/codev-sandbox/script/build-image.ts | 98 ++++++++++++-------- 1 file changed, 60 insertions(+), 38 deletions(-) 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") From 56c5eca0da70052a17f6e4b329b1efa4f17249e7 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 12 Aug 2026 11:25:18 +0700 Subject: [PATCH 2/3] feat(sandbox): add the WSL provider (sandbox Phase 3) Creates a codev-managed WSL2 distribution from the rootfs tarball with `wsl --import` and boots the same guest launcher the vz and bwrap backends use, so one server bundle and one boot.json contract cover every platform. Three Windows-specific details drive the shape: - wsl.exe emits UTF-16LE for --version/--list/--import and UTF-8 for output piped out of a distro. decodeWslOutput sniffs NUL bytes to tell them apart; reading the list as UTF-8 yields NUL-interleaved garbage that silently never matches a distro name. - WSL2 republishes a guest loopback port under the same number on the Windows loopback, so host and guest must agree on one port. The provider reserves it on the host first, which also keeps concurrent sandboxes from colliding. - Credentials go through WSLENV rather than the command line, where any host process could read them out of the process table. Windows version policy comes from the existing windowsSandboxAvailability, so Win11 is clean, Win10 22H2 warns, and nothing is ever hard-blocked. Tests drive the real code through a fake wsl.exe: distro registration, path translation, the boot script, and a create -> start -> authenticated health check -> destroy round trip. Co-Authored-By: Claude Opus 5 (1M context) --- packages/codev-sandbox/src/index.ts | 2 + packages/codev-sandbox/src/wsl/cli.ts | 77 ++++++ .../codev-sandbox/src/wsl/provider.test.ts | 161 +++++++++++++ packages/codev-sandbox/src/wsl/provider.ts | 219 ++++++++++++++++++ 4 files changed, 459 insertions(+) create mode 100644 packages/codev-sandbox/src/wsl/cli.ts create mode 100644 packages/codev-sandbox/src/wsl/provider.test.ts create mode 100644 packages/codev-sandbox/src/wsl/provider.ts 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, `'\\''`)}'` +} From 92cf880c8d0f8ff6d30b72a03576d17f0e17d819 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 12 Aug 2026 11:28:30 +0700 Subject: [PATCH 3/3] feat(sandbox): add the WSL end-to-end driver Mirrors e2e-vz.ts for Windows: serves a locally built rootfs tarball over loopback HTTP, imports the distribution, boots the guest server, and waits for the authenticated health check over WSL2 localhost forwarding. This is what makes the WSL provider runnable on a real machine before any desktop wiring exists. Two differences from e2e-vz.ts, both deliberate: - The sha256 is digested once up front rather than inside resolveImage. A hash object cannot be digested twice, so computing it in the callback breaks as soon as the provider resolves the image more than once. - --image and --bundle are resolved to absolute paths, because wslpath resolves relative paths against the guest's cwd rather than the host's. --hold keeps the distribution up for inspection; --keep skips the unregister. Co-Authored-By: Claude Opus 5 (1M context) --- packages/codev-sandbox/script/e2e-wsl.ts | 109 +++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 packages/codev-sandbox/script/e2e-wsl.ts 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 +}