diff --git a/CHANGELOG.md b/CHANGELOG.md index a76cee7706..a528383bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Reset now upgrades a sandbox to the current image** — each sandbox + records which image it was provisioned from. When a new image is + published, Reset re-provisions from it (previously the only way to pick + up a new image was deleting and re-creating the sandbox); when the image + hasn't changed, Reset stays as fast as before. Creating a sandbox over + stale on-disk state rebuilds it the same way. - **Sandboxes auto-approve permissions** — inside a sandbox, everything the agent does is confined to the VM and the one mounted project folder, so permission prompts are skipped by default: sandbox projects behave as if diff --git a/packages/codev-sandbox/src/bwrap/provider.ts b/packages/codev-sandbox/src/bwrap/provider.ts index 66ba506ec8..0dcec97c1a 100644 --- a/packages/codev-sandbox/src/bwrap/provider.ts +++ b/packages/codev-sandbox/src/bwrap/provider.ts @@ -9,7 +9,7 @@ import { sandboxBootEnv, } from "../boot" import { createBrokerServer, type SandboxEgress } from "../broker" -import { stageAsset } from "../image" +import { readImageMarker, stageAsset, writeImageMarker } from "../image" import type { SandboxAsset } from "../manifest" import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider" import { @@ -65,13 +65,20 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi const prepare = async (config: SandboxConfig, opts?: SandboxJobOptions) => { const asset = await options.resolveImage({ signal: opts?.signal }) - const image = await stageAsset(options.cacheDir, asset, { - fetchImpl, - signal: opts?.signal, - onProgress: opts?.onProgress, - }) const dirs = sandboxDirs(options.dataDir, config.name) - await unpackRootfs(image, dirs.rootfs, opts) + // reset wipes the rootfs (it is the guest's mutable state), so a matching + // marker alone is not proof — the rootfs must actually be there. + const marker = await readImageMarker(dirs.base) + const rootfs = await stat(dirs.rootfs).catch(() => null) + if (marker?.sha256 !== asset.sha256 || !rootfs?.isDirectory()) { + const image = await stageAsset(options.cacheDir, asset, { + fetchImpl, + signal: opts?.signal, + onProgress: opts?.onProgress, + }) + await unpackRootfs(image, dirs.rootfs, opts) + await writeImageMarker(dirs.base, asset) + } await mkdir(dirs.home, { recursive: true }) } diff --git a/packages/codev-sandbox/src/image.test.ts b/packages/codev-sandbox/src/image.test.ts index e18fe5d0be..4ef8e97251 100644 --- a/packages/codev-sandbox/src/image.test.ts +++ b/packages/codev-sandbox/src/image.test.ts @@ -3,7 +3,15 @@ import { createHash } from "node:crypto" import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { isAssetStaged, partialAssetPath, stageAsset, stagedAssetPath } from "./image" +import { + imageMarkerPath, + isAssetStaged, + partialAssetPath, + readImageMarker, + stageAsset, + stagedAssetPath, + writeImageMarker, +} from "./image" const payload = new TextEncoder().encode("codev sandbox image payload") const payloadSha256 = createHash("sha256").update(payload).digest("hex") @@ -230,3 +238,19 @@ test("sweeps stale partials from older downloads", async () => { server.stop() await rm(cacheDir, { recursive: true, force: true }) }) + +test("image markers round-trip and read as null when unreadable", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-sandbox-")) + const asset = { url: "https://host/sandbox/1.0/vz-arm64.tar.gz", sha256: "d".repeat(64), bytes: 42 } + + expect(await readImageMarker(dir)).toBe(null) + await writeImageMarker(dir, asset) + expect(await readImageMarker(dir)).toEqual(asset) + + await Bun.write(imageMarkerPath(dir), "not json at all") + expect(await readImageMarker(dir)).toBe(null) + await Bun.write(imageMarkerPath(dir), JSON.stringify({ sha256: 5 })) + expect(await readImageMarker(dir)).toBe(null) + + await rm(dir, { recursive: true, force: true }) +}) diff --git a/packages/codev-sandbox/src/image.ts b/packages/codev-sandbox/src/image.ts index f4a9691d44..98effbc729 100644 --- a/packages/codev-sandbox/src/image.ts +++ b/packages/codev-sandbox/src/image.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto" import { createReadStream, createWriteStream } from "node:fs" -import { mkdir, readdir, rename, rm, stat } from "node:fs/promises" +import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises" import { finished } from "node:stream/promises" import { basename, dirname, join } from "node:path" import type { SandboxAsset } from "./manifest" @@ -191,3 +191,33 @@ function assetFileName(asset: SandboxAsset) { const name = basename(new URL(asset.url).pathname) return name.length ? name : "asset" } + +// Each provisioned sandbox records which image it was built from, so create +// can skip work when current, and reset doubles as the upgrade path when the +// manifest has moved on. Anything unreadable reads as "unknown" and triggers +// a re-provision — the safe direction. +export function imageMarkerPath(dir: string) { + return join(dir, "image.json") +} + +export async function readImageMarker(dir: string): Promise { + const raw = await readFile(imageMarkerPath(dir), "utf8").catch(() => null) + if (!raw) return null + const parsed: unknown = (() => { + try { + return JSON.parse(raw) + } catch { + return null + } + })() + if (!parsed || typeof parsed !== "object") return null + const record = parsed as Record + if (typeof record.url !== "string" || typeof record.sha256 !== "string" || typeof record.bytes !== "number") + return null + return { url: record.url, sha256: record.sha256, bytes: record.bytes } +} + +export async function writeImageMarker(dir: string, asset: SandboxAsset) { + await mkdir(dir, { recursive: true }) + await writeFile(imageMarkerPath(dir), `${JSON.stringify(asset, null, 2)}\n`) +} diff --git a/packages/codev-sandbox/src/vz/provider.test.ts b/packages/codev-sandbox/src/vz/provider.test.ts index 9e887cf023..3af04d729c 100644 --- a/packages/codev-sandbox/src/vz/provider.test.ts +++ b/packages/codev-sandbox/src/vz/provider.test.ts @@ -218,6 +218,64 @@ test("create unpacks the image, validates its contents, and clones the instance await rm(dir, { recursive: true, force: true }) }) +test("reset re-clones when current and re-provisions when the image moved on", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-vz-")) + const buildTar = async (version: string) => { + const tree = join(dir, `tree-${version}`) + await mkdir(tree, { recursive: true }) + await writeFile(join(tree, "vmlinux"), "kernel") + await writeFile(join(tree, "rootfs.img"), `disk-${version}`) + const tarPath = join(dir, `vz-${version}.tar`) + expect((await runCommand("tar", ["-cf", tarPath, "-C", tree, "vmlinux", "rootfs.img"])).code).toBe(0) + return await Bun.file(tarPath).bytes() + } + const payloads = { v1: await buildTar("v1"), v2: await buildTar("v2") } + let active: keyof typeof payloads = "v1" + let downloads = 0 + const server = Bun.serve({ + port: 0, + fetch: () => { + downloads += 1 + return new Response(payloads[active]) + }, + }) + + const options: VzProviderOptions = { + dataDir: join(dir, "data"), + cacheDir: join(dir, "cache"), + bundleDir: join(dir, "bundle"), + helperPath: join(dir, "codev-vmm"), + platform: "darwin", + resolveImage: async () => ({ + url: `http://127.0.0.1:${server.port}/vz-${active}.tar`, + sha256: createHash("sha256").update(payloads[active]).digest("hex"), + bytes: payloads[active].byteLength, + }), + } + const provider = createVzProvider(options) + const dirs = vzSandboxDirs(options.dataDir, config.name) + + await provider.create(config) + expect(await readFile(dirs.disk, "utf8")).toBe("disk-v1") + expect(downloads).toBe(1) + + // Current image: reset re-clones without touching the network. + await writeFile(dirs.disk, "scribbles") + await provider.reset(config) + expect(await readFile(dirs.disk, "utf8")).toBe("disk-v1") + expect(downloads).toBe(1) + + // Manifest moved on: the same reset re-provisions from the new image. + active = "v2" + await provider.reset(config) + expect(await readFile(dirs.disk, "utf8")).toBe("disk-v2") + expect(downloads).toBe(2) + + await provider.destroy(config) + server.stop(true) + await rm(dir, { recursive: true, force: true }) +}) + test("create rejects an image tarball missing required files", async () => { const dir = await mkdtemp(join(tmpdir(), "codev-vz-")) const tree = join(dir, "tree") diff --git a/packages/codev-sandbox/src/vz/provider.ts b/packages/codev-sandbox/src/vz/provider.ts index 59e992de8e..186f1daa09 100644 --- a/packages/codev-sandbox/src/vz/provider.ts +++ b/packages/codev-sandbox/src/vz/provider.ts @@ -5,7 +5,7 @@ import { checkSandboxHealth, generateSandboxCredentials, pollSandboxHealth, sand import { createBrokerServer, type SandboxEgress } from "../broker" import { forwardTcpToUnix } from "../bwrap/bridge" import { runCommand, unpackRootfs } from "../bwrap/rootfs" -import { stageAsset } from "../image" +import { readImageMarker, stageAsset, writeImageMarker } from "../image" import type { SandboxAsset } from "../manifest" import { macosSandboxAvailability } from "../policy" import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider" @@ -64,29 +64,36 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { const startTimeoutMs = options.startTimeoutMs ?? 60_000 const readMacosVersion = options.readMacosVersion ?? defaultMacosVersion - const prepare = async (config: SandboxConfig, opts?: SandboxJobOptions) => { - const asset = await options.resolveImage({ signal: opts?.signal }) - const image = await stageAsset(options.cacheDir, asset, { - fetchImpl, - signal: opts?.signal, - onProgress: opts?.onProgress, - }) + const provision = async (config: SandboxConfig, asset: SandboxAsset, opts?: SandboxJobOptions) => { const dirs = vzSandboxDirs(options.dataDir, config.name) - await unpackRootfs(image, dirs.image, opts) - // initrd is optional: the pipeline's custom kernel has virtio + ext4 - // compiled in and boots without one. - const missing = [] - for (const file of [VZ_KERNEL_FILE, VZ_DISK_FILE]) { - const found = await stat(join(dirs.image, file)).catch(() => null) - if (!found?.isFile()) missing.push(file) + const marker = await readImageMarker(dirs.base) + if (marker?.sha256 !== asset.sha256) { + const image = await stageAsset(options.cacheDir, asset, { + fetchImpl, + signal: opts?.signal, + onProgress: opts?.onProgress, + }) + await unpackRootfs(image, dirs.image, opts) + // initrd is optional: the pipeline's custom kernel has virtio + ext4 + // compiled in and boots without one. + const missing = [] + for (const file of [VZ_KERNEL_FILE, VZ_DISK_FILE]) { + const found = await stat(join(dirs.image, file)).catch(() => null) + if (!found?.isFile()) missing.push(file) + } + if (missing.length) throw new Error(`vz image is missing ${missing.join(", ")}`) + await writeImageMarker(dirs.base, asset) } - if (missing.length) throw new Error(`vz image is missing ${missing.join(", ")}`) // The pristine unpacked disk stays untouched; the instance runs a // copy-on-write clone (APFS clonefile via FICLONE, plain copy elsewhere), // so reset is just re-cloning. await copyFile(join(dirs.image, VZ_DISK_FILE), dirs.disk, constants.COPYFILE_FICLONE) } + const prepare = async (config: SandboxConfig, opts?: SandboxJobOptions) => { + await provision(config, await options.resolveImage({ signal: opts?.signal }), opts) + } + return { backend: "vz", @@ -258,12 +265,14 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { await rm(vzSandboxDirs(options.dataDir, config.name).base, { recursive: true, force: true }) }, - async reset(config) { + async reset(config, opts) { const dirs = vzSandboxDirs(options.dataDir, config.name) - // Guest state lives in the instance disk; re-cloning the pristine image - // disk resets it without re-downloading anything. + // Guest state lives in the instance disk. When the manifest still points + // at the image this sandbox was built from, reset is just re-cloning the + // pristine disk; when it has moved on, reset doubles as the upgrade path + // and re-provisions from the current image. await rm(dirs.disk, { force: true }) - await copyFile(join(dirs.image, VZ_DISK_FILE), dirs.disk, constants.COPYFILE_FICLONE) + await provision(config, await options.resolveImage(opts), opts) }, } } diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index 6fd5a591a3..3db2a8554a 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -25,7 +25,7 @@ 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) + const value = read(); value.distros.push(args[1]); value.imports = (value.imports ?? 0) + 1; write(value) out("The operation completed successfully.\\r\\n"); process.exit(0) } if (args[0] === "--terminate") { process.exit(0) } @@ -146,6 +146,22 @@ test("create imports the distro once and is idempotent", async () => { await provider.create(config) const state = JSON.parse(await Bun.file(join(dir, "state.json")).text()) expect(state.distros).toEqual([wslDistroName(config.name)]) + expect(state.imports).toBe(1) + 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)) + await provider.create(config) + + // Simulate a manifest bump: the recorded marker no longer matches. + await Bun.write(join(dir, "data", config.name, "image.json"), JSON.stringify({ url: "u", sha256: "0".repeat(64), bytes: 1 })) + await provider.create(config) + + const state = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + expect(state.distros).toEqual([wslDistroName(config.name)]) + expect(state.imports).toBe(2) 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 index ab94cf8731..70bf19d55d 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -10,7 +10,7 @@ import { sandboxBootEnv, } from "../boot" import { createBrokerServer, type SandboxEgress } from "../broker" -import { stageAsset } from "../image" +import { readImageMarker, stageAsset, writeImageMarker } from "../image" import type { SandboxAsset } from "../manifest" import { windowsBuildNumber, windowsSandboxAvailability } from "../policy" import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider" @@ -103,19 +103,33 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider const prepare = async (config: SandboxConfig, opts?: SandboxJobOptions) => { const distro = wslDistroName(config.name) const asset = await options.resolveImage({ signal: opts?.signal }) + const dirs = wslSandboxDirs(options.dataDir, config.name) + if (await registered(distro)) { + // Registered on the current image: nothing to do. A stale (or unknown) + // image means the distro must be rebuilt — an import cannot overlay an + // existing distribution. + const marker = await readImageMarker(dirs.base) + if (marker?.sha256 === asset.sha256) return + 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}`}`, + ) + await rm(dirs.install, { recursive: true, force: true }) + } const tarball = await stageAsset(options.cacheDir, asset, { fetchImpl, signal: opts?.signal, onProgress: opts?.onProgress, }) - 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"], { timeoutMs: WSL_IMPORT_TIMEOUT_MS, }) if (imported.code !== 0) throw new Error(`wsl --import failed for ${distro}: ${imported.stderr.trim() || `exited ${imported.code}`}`) + await writeImageMarker(dirs.base, asset) } return {