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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 14 additions & 7 deletions packages/codev-sandbox/src/bwrap/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 })
}

Expand Down
26 changes: 25 additions & 1 deletion packages/codev-sandbox/src/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 })
})
32 changes: 31 additions & 1 deletion packages/codev-sandbox/src/image.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<SandboxAsset | null> {
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<string, unknown>
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`)
}
58 changes: 58 additions & 0 deletions packages/codev-sandbox/src/vz/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
49 changes: 29 additions & 20 deletions packages/codev-sandbox/src/vz/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",

Expand Down Expand Up @@ -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)
},
}
}
Expand Down
18 changes: 17 additions & 1 deletion packages/codev-sandbox/src/wsl/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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 })
})

Expand Down
20 changes: 17 additions & 3 deletions packages/codev-sandbox/src/wsl/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
Loading