diff --git a/packages/codev-sandbox/src/bwrap/args.test.ts b/packages/codev-sandbox/src/bwrap/args.test.ts index 7ab4b44c99..16a825884a 100644 --- a/packages/codev-sandbox/src/bwrap/args.test.ts +++ b/packages/codev-sandbox/src/bwrap/args.test.ts @@ -65,6 +65,23 @@ test("never nests a mount destination inside a read-only rootfs bind", () => { expect(destinations.filter((destination) => destination.startsWith("/opt/"))).toEqual(["/opt/codev"]) }) +test("binds extra mounts rw under /mnt, after the rootfs binds", () => { + const joined = buildBwrapArgs({ + ...spec, + rootfsEntries: [...spec.rootfsEntries, { name: "mnt", symlinkTarget: null }], + mounts: [ + { path: "/host/docs", guestPath: "/mnt/docs" }, + { path: "/host/data", guestPath: "/mnt/data" }, + ], + }).join(" ") + expect(joined).toContain("--bind /host/docs /mnt/docs") + expect(joined).toContain("--bind /host/data /mnt/data") + // The image's own /mnt must not be ro-bound: bwrap can only create the + // /mnt/ mount points on its tmpfs root. + expect(joined).not.toContain("/rootfs/mnt") + expect(joined.indexOf("--bind /host/docs")).toBeGreaterThan(joined.indexOf("--ro-bind /data/default/rootfs/usr")) +}) + test("clears the environment before setting the boot env", () => { const args = buildBwrapArgs(spec) const joined = args.join(" ") diff --git a/packages/codev-sandbox/src/bwrap/args.ts b/packages/codev-sandbox/src/bwrap/args.ts index 9b30666a14..f6c208d15e 100644 --- a/packages/codev-sandbox/src/bwrap/args.ts +++ b/packages/codev-sandbox/src/bwrap/args.ts @@ -24,6 +24,8 @@ export type BwrapSpec = { rootfsDir: string rootfsEntries: RootfsEntry[] projectDir: string + // Extra host folders, already resolved to their /mnt/ guest paths. + mounts?: { path: string; guestPath: string }[] bundleDir: string bridgeDir: string runtimeDir: string @@ -33,7 +35,9 @@ export type BwrapSpec = { } // Mounts we manage ourselves; a rootfs copy of these must never shadow them. -const MANAGED_ROOTFS_ENTRIES = new Set(["proc", "dev", "sys", "tmp", "run", "home", "workspace"]) +// "mnt" is here so extra-mount points under /mnt land on bwrap's tmpfs root, +// where bwrap can create them — inside a read-only rootfs bind it cannot. +const MANAGED_ROOTFS_ENTRIES = new Set(["proc", "dev", "sys", "tmp", "run", "home", "workspace", "mnt"]) export function bindableRootfsEntries(entries: RootfsEntry[]) { return entries.filter((entry) => !MANAGED_ROOTFS_ENTRIES.has(entry.name)) @@ -66,6 +70,7 @@ export function buildBwrapArgs(spec: BwrapSpec) { "--bind", spec.projectDir, GUEST_WORKSPACE, + ...(spec.mounts ?? []).flatMap((mount) => ["--bind", mount.path, mount.guestPath]), "--ro-bind", spec.bundleDir, GUEST_BUNDLE, diff --git a/packages/codev-sandbox/src/bwrap/provider.ts b/packages/codev-sandbox/src/bwrap/provider.ts index cbaf925c84..19037fea79 100644 --- a/packages/codev-sandbox/src/bwrap/provider.ts +++ b/packages/codev-sandbox/src/bwrap/provider.ts @@ -11,6 +11,7 @@ import { import { createBrokerServer, type SandboxEgress } from "../broker" import { readImageMarker, stageAsset, writeImageMarker } from "../image" import type { SandboxAsset } from "../manifest" +import { resolveGuestMounts } from "../mounts" import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider" import { GUEST_BRIDGE, @@ -114,6 +115,17 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi await mkdir(dirs.bridge, { recursive: true }) await mkdir(dirs.home, { recursive: true }) await writeFile(join(dirs.bridge, "forward.mjs"), GUEST_FORWARD_SCRIPT) + // Extra mounts, unlike projectDir, must not brick the start: a folder + // deleted since it was added is skipped, not fatal. + const accessible = [] + for (const mount of config.mounts ?? []) { + const found = await stat(mount.path).catch(() => null) + if (found?.isDirectory()) accessible.push(mount) + } + const guestMounts = resolveGuestMounts(accessible).map((mount) => ({ + path: mount.path, + guestPath: mount.guestPath, + })) // Listening before boot: the guest-side bridge dials broker.sock per // connection and drops it when nothing serves the socket. @@ -142,6 +154,7 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi rootfsDir: dirs.rootfs, rootfsEntries: await readRootfsEntries(dirs.rootfs), projectDir: config.projectDir, + mounts: guestMounts, bundleDir: options.bundleDir, bridgeDir: dirs.bridge, runtimeDir: dirs.runtime, @@ -198,6 +211,7 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi url, username: credentials.username, password: credentials.password, + mounts: guestMounts, stop: () => { child.kill() void forwarder.close() diff --git a/packages/codev-sandbox/src/controller.test.ts b/packages/codev-sandbox/src/controller.test.ts index cc40335320..7426cb7bd3 100644 --- a/packages/codev-sandbox/src/controller.test.ts +++ b/packages/codev-sandbox/src/controller.test.ts @@ -13,6 +13,7 @@ function fakeRunning(url: string): FakeRunning { url, username: "codev", password: "secret", + mounts: [], stopped: false, stop: () => { sandbox.stopped = true @@ -70,7 +71,13 @@ test("adds a sandbox, persists it, and reports the running server", async () => const runtime = await waitForRuntimeKind(controller, config.id, "ready") expect(created).toBe(1) expect(persisted).toEqual([config]) - expect(runtime).toEqual({ kind: "ready", url: "http://127.0.0.1:4096", username: "codev", password: "secret" }) + expect(runtime).toEqual({ + kind: "ready", + url: "http://127.0.0.1:4096", + username: "codev", + password: "secret", + mounts: [], + }) expect(controller.getState().job).toBe(null) }) @@ -217,7 +224,13 @@ test("ignores stale sandboxes after a rapid restart", async () => { const runtime = await waitForRuntimeKind(controller, config.id, "ready") expect(stale.stopped).toBe(true) expect(fresh.stopped).toBe(false) - expect(runtime).toEqual({ kind: "ready", url: "http://127.0.0.1:2222", username: "codev", password: "secret" }) + expect(runtime).toEqual({ + kind: "ready", + url: "http://127.0.0.1:2222", + username: "codev", + password: "secret", + mounts: [], + }) }) test("removing a sandbox stops it, destroys it, and forgets it", async () => { @@ -269,7 +282,156 @@ test("reset destroys guest state and restarts the sandbox", async () => { const runtime = await waitForRuntimeKind(controller, config.id, "ready") expect(resets).toBe(1) expect(starts).toBe(2) - expect(runtime).toEqual({ kind: "ready", url: "http://127.0.0.1:5002", username: "codev", password: "secret" }) + expect(runtime).toEqual({ + kind: "ready", + url: "http://127.0.0.1:5002", + username: "codev", + password: "secret", + mounts: [], + }) +}) + +test("updating mounts on a failed sandbox persists without starting it", async () => { + let persisted: SandboxConfig[] = [config] + let starts = 0 + const controller = createSandboxController({ + providers: { + bwrap: fakeProvider({ + start: async () => { + starts++ + throw new Error("down") + }, + }), + }, + readSandboxes: () => persisted, + writeSandboxes: (sandboxes) => { + persisted = sandboxes + }, + }) + await controller.initialize() + await waitForRuntimeKind(controller, config.id, "failed") + const startsBefore = starts + await controller.updateSandboxMounts(config.id, [{ path: "/extra" }]) + expect(starts).toBe(startsBefore) + expect(persisted).toEqual([{ ...config, mounts: [{ path: "/extra" }] }]) + expect(controller.getState().sandboxes[0]?.config.mounts).toEqual([{ path: "/extra" }]) +}) + +test("updating mounts restarts a running sandbox when the provider cannot apply hot", async () => { + let persisted: SandboxConfig[] = [config] + const startConfigs: SandboxConfig[] = [] + const instances: FakeRunning[] = [] + const controller = createSandboxController({ + providers: { + bwrap: fakeProvider({ + start: async (startConfig) => { + startConfigs.push(startConfig) + const instance = fakeRunning(`http://127.0.0.1:600${startConfigs.length}`) + instances.push(instance) + return instance + }, + }), + }, + readSandboxes: () => persisted, + writeSandboxes: (sandboxes) => { + persisted = sandboxes + }, + }) + await controller.initialize() + await waitForRuntimeKind(controller, config.id, "ready") + await controller.updateSandboxMounts(config.id, [{ path: "/extra" }]) + await waitForRuntimeKind(controller, config.id, "ready") + expect(startConfigs).toHaveLength(2) + expect(startConfigs[1]?.mounts).toEqual([{ path: "/extra" }]) + expect(instances[0]?.stopped).toBe(true) + expect(instances[1]?.stopped).toBe(false) + expect(persisted).toEqual([{ ...config, mounts: [{ path: "/extra" }] }]) +}) + +test("updating mounts applies hot when the provider supports it", async () => { + let persisted: SandboxConfig[] = [config] + let starts = 0 + let applied: SandboxConfig | undefined + const controller = createSandboxController({ + providers: { + bwrap: fakeProvider({ + start: async () => { + starts++ + return fakeRunning("http://127.0.0.1:4096") + }, + applyMounts: async (applyConfig) => { + applied = applyConfig + return [{ path: "/extra", guestPath: "/mnt/extra" }] + }, + }), + }, + readSandboxes: () => persisted, + writeSandboxes: (sandboxes) => { + persisted = sandboxes + }, + }) + await controller.initialize() + await waitForRuntimeKind(controller, config.id, "ready") + await controller.updateSandboxMounts(config.id, [{ path: "/extra" }]) + expect(starts).toBe(1) + expect(applied?.mounts).toEqual([{ path: "/extra" }]) + expect(persisted).toEqual([{ ...config, mounts: [{ path: "/extra" }] }]) + const runtime = runtimeOf(controller, config.id) + expect(runtime).toEqual({ + kind: "ready", + url: "http://127.0.0.1:4096", + username: "codev", + password: "secret", + mounts: [{ path: "/extra", guestPath: "/mnt/extra" }], + }) +}) + +test("a failed hot apply persists nothing", async () => { + let persisted: SandboxConfig[] = [config] + const controller = createSandboxController({ + providers: { + bwrap: fakeProvider({ + applyMounts: async () => { + throw new Error("folder does not exist") + }, + }), + }, + readSandboxes: () => persisted, + writeSandboxes: (sandboxes) => { + persisted = sandboxes + }, + }) + await controller.initialize() + await waitForRuntimeKind(controller, config.id, "ready") + expect(controller.updateSandboxMounts(config.id, [{ path: "/gone" }])).rejects.toThrow("folder does not exist") + expect(persisted).toEqual([config]) + expect(controller.getState().sandboxes[0]?.config.mounts).toBeUndefined() +}) + +test("updating mounts is rejected while the sandbox has a job in flight", async () => { + let persisted: SandboxConfig[] = [config] + let resolveReset: (() => void) | undefined + const controller = createSandboxController({ + providers: { + bwrap: fakeProvider({ + reset: () => + new Promise((resolve) => { + resolveReset = () => resolve(undefined) + }), + }), + }, + readSandboxes: () => persisted, + writeSandboxes: (sandboxes) => { + persisted = sandboxes + }, + }) + await controller.initialize() + await waitForRuntimeKind(controller, config.id, "ready") + const resetting = controller.resetSandbox(config.id) + expect(controller.updateSandboxMounts(config.id, [{ path: "/extra" }])).rejects.toThrow("operation in progress") + resolveReset?.() + await resetting + expect(persisted).toEqual([config]) }) test("stopAll stops running sandboxes and invalidates pending starts", async () => { diff --git a/packages/codev-sandbox/src/controller.ts b/packages/codev-sandbox/src/controller.ts index 5771b792c1..9ee50a950e 100644 --- a/packages/codev-sandbox/src/controller.ts +++ b/packages/codev-sandbox/src/controller.ts @@ -3,6 +3,7 @@ import type { SandboxBackend, SandboxConfig, SandboxItem, + SandboxMount, SandboxProgress, SandboxProvider, SandboxRuntime, @@ -155,6 +156,7 @@ export function createSandboxController(options: SandboxControllerOptions) { url: sandbox.url, username: sandbox.username, password: sandbox.password, + mounts: sandbox.mounts, }) sandbox.onExit((code, signal) => { if (running.get(id) !== sandbox) return @@ -244,6 +246,43 @@ export function createSandboxController(options: SandboxControllerOptions) { await startSandbox(id) }, + // Replaces the extra-mount list. Applied hot where the provider can + // (WSL), otherwise by restarting a live sandbox; a stopped or failed + // sandbox just persists the change for its next start. + async updateSandboxMounts(id: string, mounts: SandboxMount[]) { + const item = state.sandboxes.find((x) => x.config.id === id) + if (!item) throw new Error(`no sandbox with id ${id}`) + if (state.job?.id === id) throw new Error("the sandbox has an operation in progress; wait for it to finish") + const provider = requireProvider(item.config.backend) + const config = { ...item.config, mounts } + // The persisted store and state.sandboxes[].config are separate; both + // must move together (see addSandbox). + const persist = () => { + options.writeSandboxes(options.readSandboxes().map((c) => (c.id === id ? { ...c, mounts } : c))) + setState({ sandboxes: state.sandboxes.map((x) => (x.config.id === id ? { ...x, config } : x)) }) + } + const current = running.get(id) + if (current && provider.applyMounts) { + // Hot path: apply against the live guest before persisting, so a bad + // path never lands in the stored config. + const resolved = await provider.applyMounts(config) + persist() + // The instance can change under the await; the persisted config still + // wins on the next start, but the stale runtime must not be patched. + if (running.get(id) !== current) return + const runtime = state.sandboxes.find((x) => x.config.id === id)?.runtime + if (runtime?.kind === "ready") setRuntime(id, { ...runtime, mounts: resolved }) + logger?.log("sandbox mounts applied", { id, mounts: resolved.length }) + return + } + // Restart path: persist first so the new mount set survives a failed + // restart, then restart only a live sandbox. + persist() + if (item.runtime.kind !== "ready" && item.runtime.kind !== "starting") return + logger?.log("sandbox restarting to apply mounts", { id }) + await startSandbox(id) + }, + startSandbox, stopAll() { diff --git a/packages/codev-sandbox/src/index.ts b/packages/codev-sandbox/src/index.ts index c702ed3146..fae08b2fc4 100644 --- a/packages/codev-sandbox/src/index.ts +++ b/packages/codev-sandbox/src/index.ts @@ -1,6 +1,7 @@ export * from "./provider" export * from "./policy" export * from "./controller" +export * from "./mounts" export * from "./manifest" export * from "./image" export * from "./broker" diff --git a/packages/codev-sandbox/src/mounts.test.ts b/packages/codev-sandbox/src/mounts.test.ts new file mode 100644 index 0000000000..1787154c76 --- /dev/null +++ b/packages/codev-sandbox/src/mounts.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test" +import { mountGuestName, resolveGuestMounts, validateMountPaths } from "./mounts" + +test("guest name is the sanitized basename", () => { + expect(mountGuestName("/home/user/my repo", new Set())).toBe("my-repo") + expect(mountGuestName("/home/user/docs/", new Set())).toBe("docs") + expect(mountGuestName("C:\\Users\\dev\\Notes", new Set())).toBe("Notes") + expect(mountGuestName("/home/user/.config", new Set())).toBe("config") +}) + +test("guest name falls back and dedupes", () => { + expect(mountGuestName("/héllo/世界", new Set())).toBe("folder") + expect(mountGuestName("/a/data", new Set(["data"]))).toBe("data-2") + expect(mountGuestName("/b/data", new Set(["data", "data-2"]))).toBe("data-3") +}) + +test("guest name keeps the virtiofs tag under its 36-byte cap", () => { + const name = mountGuestName(`/deep/${"x".repeat(80)}`, new Set()) + expect(name.length).toBeLessThanOrEqual(24) + expect(`mnt-${name}-99`.length).toBeLessThanOrEqual(36) +}) + +test("resolveGuestMounts maps to /mnt paths with stable in-order dedup", () => { + const resolved = resolveGuestMounts([{ path: "/a/data" }, { path: "/b/data" }, { path: "/c/other" }]) + expect(resolved).toEqual([ + { path: "/a/data", guestName: "data", guestPath: "/mnt/data", tag: "mnt-data" }, + { path: "/b/data", guestName: "data-2", guestPath: "/mnt/data-2", tag: "mnt-data-2" }, + { path: "/c/other", guestName: "other", guestPath: "/mnt/other", tag: "mnt-other" }, + ]) +}) + +test("validateMountPaths accepts distinct sibling folders", () => { + expect(validateMountPaths("/home/user/project", [{ path: "/home/user/docs" }, { path: "/srv/data" }])).toBeNull() +}) + +test("validateMountPaths rejects duplicates, case-insensitively for Windows paths", () => { + expect(validateMountPaths("/p", [{ path: "/a" }, { path: "/a/" }])).toEqual({ code: "duplicate", path: "/a/" }) + expect(validateMountPaths("C:\\p", [{ path: "C:\\Data" }, { path: "c:\\data" }])).toEqual({ + code: "duplicate", + path: "c:\\data", + }) + // Unix paths stay case-sensitive. + expect(validateMountPaths("/p", [{ path: "/a/Data" }, { path: "/a/data" }])).toBeNull() +}) + +test("validateMountPaths rejects nesting in either direction", () => { + expect(validateMountPaths("/home/user/project", [{ path: "/home/user/project/src" }])).toEqual({ + code: "nested", + path: "/home/user/project/src", + other: "/home/user/project", + }) + expect(validateMountPaths("/home/user/project", [{ path: "/home/user" }])).toEqual({ + code: "nested", + path: "/home/user", + other: "/home/user/project", + }) + expect(validateMountPaths("/p", [{ path: "/data" }, { path: "/data/sub" }])).toEqual({ + code: "nested", + path: "/data/sub", + other: "/data", + }) + // Shared prefixes without a separator boundary are not nesting. + expect(validateMountPaths("/p", [{ path: "/data" }, { path: "/database" }])).toBeNull() +}) + +test("validateMountPaths rejects nesting against a Windows project dir", () => { + expect(validateMountPaths("C:\\Users\\dev\\proj", [{ path: "c:\\users\\dev\\proj\\docs" }])).toEqual({ + code: "nested", + path: "c:\\users\\dev\\proj\\docs", + other: "C:\\Users\\dev\\proj", + }) +}) + +test("validateMountPaths rejects UNC paths", () => { + expect(validateMountPaths("C:\\p", [{ path: "\\\\server\\share" }])).toEqual({ + code: "unc", + path: "\\\\server\\share", + }) +}) diff --git a/packages/codev-sandbox/src/mounts.ts b/packages/codev-sandbox/src/mounts.ts new file mode 100644 index 0000000000..a3bb2ded75 --- /dev/null +++ b/packages/codev-sandbox/src/mounts.ts @@ -0,0 +1,75 @@ +import type { SandboxMount } from "./provider" + +// virtio-fs tags cap at 36 bytes; "mnt-" plus a 24-char name plus a dedup +// suffix stays under it. +const MOUNT_GUEST_NAME_MAX = 24 + +export type GuestMount = { + path: string + guestName: string + guestPath: string + tag: string +} + +// Derives the guest-visible name for a mounted host folder from its basename. +// Handles both separators because WSL sandbox configs carry Windows paths. +export function mountGuestName(path: string, taken: Set) { + const base = path.replace(/[/\\]+$/, "").split(/[/\\]/).pop() ?? "" + const sanitized = base + .replace(/[^A-Za-z0-9._-]/g, "-") + .replace(/^[.-]+/, "") + .slice(0, MOUNT_GUEST_NAME_MAX) + const name = sanitized.length ? sanitized : "folder" + if (!taken.has(name)) return name + const suffixed = (i: number): string => (taken.has(`${name}-${i}`) ? suffixed(i + 1) : `${name}-${i}`) + return suffixed(2) +} + +// Maps host mounts onto /mnt/ guest paths (vz and bwrap; WSL translates +// through wslpath instead). Names dedupe in list order, so a mount's guest +// path is stable as long as the entries before it keep their basenames. +export function resolveGuestMounts(mounts: SandboxMount[]): GuestMount[] { + const taken = new Set() + return mounts.map((mount) => { + const guestName = mountGuestName(mount.path, taken) + taken.add(guestName) + return { path: mount.path, guestName, guestPath: `/mnt/${guestName}`, tag: `mnt-${guestName}` } + }) +} + +export type MountValidationError = + | { code: "duplicate"; path: string } + | { code: "nested"; path: string; other: string } + | { code: "unc"; path: string } + +// Pure structural validation shared by the desktop IPC handler and (mirrored, +// not imported) the app dialog: no duplicates, no mount nested inside another +// mount or the project directory (it is already reachable there), no UNC +// paths (wslpath cannot automount them). Existence is checked separately by +// whoever owns filesystem access. +export function validateMountPaths(projectDir: string, mounts: SandboxMount[]): MountValidationError | null { + const project = comparableMountPath(projectDir) + const seen = new Map() + for (const mount of mounts) { + if (mount.path.startsWith("\\\\")) return { code: "unc", path: mount.path } + const normalized = comparableMountPath(mount.path) + if (seen.has(normalized)) return { code: "duplicate", path: mount.path } + if (nests(normalized, project)) return { code: "nested", path: mount.path, other: projectDir } + for (const [other, original] of seen) { + if (nests(normalized, other)) return { code: "nested", path: mount.path, other: original } + } + seen.set(normalized, mount.path) + } + return null +} + +// Windows paths compare case-insensitively; detection is by shape (drive +// letter or backslash) because this runs for records from either platform. +function comparableMountPath(path: string) { + const canonical = path.replace(/[/\\]+$/, "").replace(/\\/g, "/") + return /^[A-Za-z]:|\\/.test(path) ? canonical.toLowerCase() : canonical +} + +function nests(a: string, b: string) { + return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) +} diff --git a/packages/codev-sandbox/src/provider.ts b/packages/codev-sandbox/src/provider.ts index 7356584412..6ee6d66b5b 100644 --- a/packages/codev-sandbox/src/provider.ts +++ b/packages/codev-sandbox/src/provider.ts @@ -7,6 +7,11 @@ export type SandboxConfig = { name: string backend: SandboxBackend projectDir: string + // Extra host folders shared into the guest alongside projectDir. Host paths + // only; guest paths are derived at start (they depend on the backend), and + // absent means none. Shaped as objects so a readOnly flag can be added + // without migrating persisted records. + mounts?: SandboxMount[] // Per-sandbox limits, applied by the vz backend at boot. Omitted means the // provider default. WSL and bubblewrap ignore them: WSL's limits live in // the user's VM-global .wslconfig, and bubblewrap has no cgroup plumbing @@ -15,6 +20,18 @@ export type SandboxConfig = { memoryMiB?: number } +export type SandboxMount = { + path: string +} + +// A mount as the running guest actually sees it: guestPath is /mnt/ on +// vz/bwrap and the wslpath translation on WSL, and mounts the provider +// skipped (folder deleted since it was added) are absent entirely. +export type ResolvedSandboxMount = { + path: string + guestPath: string +} + // Ceilings keep an unusable VM (0 cpus, 64MiB) or an oversubscribed host out // of the persisted config; the guest needs real headroom for the office // toolchain. Both ends inclusive. @@ -29,7 +46,7 @@ export function sandboxResourcesValid(config: { cpus?: number; memoryMiB?: numbe export type SandboxRuntime = | { kind: "starting" } - | { kind: "ready"; url: string; username: string | null; password: string | null } + | { kind: "ready"; url: string; username: string | null; password: string | null; mounts: ResolvedSandboxMount[] } | { kind: "failed"; message: string } | { kind: "stopped" } @@ -42,6 +59,7 @@ export type RunningSandbox = { url: string username: string | null password: string + mounts: ResolvedSandboxMount[] stop: () => void onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } @@ -65,6 +83,13 @@ export type SandboxProvider = { start(config: SandboxConfig, opts?: { signal?: AbortSignal }): Promise destroy(config: SandboxConfig, opts?: { signal?: AbortSignal }): Promise reset(config: SandboxConfig, opts?: SandboxJobOptions): Promise + // Applies config.mounts to the already-running sandbox without a restart. + // Only backends where that is possible implement it (WSL: the distro's + // mount namespace is shared, and automounted drives need no guest work at + // all); absent means the controller restarts the sandbox instead. Must + // throw on an unusable mount — it runs before the config is persisted and + // doubles as the validation gate for the hot path. + applyMounts?(config: SandboxConfig): Promise } export function sandboxServerId(name: string) { diff --git a/packages/codev-sandbox/src/vz/provider.test.ts b/packages/codev-sandbox/src/vz/provider.test.ts index df9c8692ba..3b7b686743 100644 --- a/packages/codev-sandbox/src/vz/provider.test.ts +++ b/packages/codev-sandbox/src/vz/provider.test.ts @@ -136,6 +136,10 @@ test("starts the VM via the helper protocol and serves health through the vsock expect(boot.env.CODEV_SERVER_PASSWORD).toBe(sandbox.password) expect(boot.env.CODEV_SERVER_HOSTNAME).toBe("127.0.0.1") expect(boot.env.CODEV_SERVER_PORT).toBe("4096") + // No extra mounts: the manifest is still written so a stale list from a + // previous boot cannot survive. + expect(JSON.parse(await readFile(join(prepared.dirs.guestConfig, "mounts.json"), "utf8"))).toEqual({ mounts: [] }) + expect(sandbox.mounts).toEqual([]) let exited = false sandbox.onExit(() => { @@ -149,6 +153,34 @@ test("starts the VM via the helper protocol and serves health through the vsock await rm(dir, { recursive: true, force: true }) }) +test("shares extra mounts into the guest and skips deleted folders", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-vz-")) + const prepared = await preparedOptions(dir, FAKE_HELPER) + const provider = createVzProvider(prepared.options) + const docs = join(dir, "docs") + await mkdir(docs, { recursive: true }) + + const sandbox = await provider.start({ + ...config, + projectDir: prepared.projectDir, + mounts: [{ path: docs }, { path: join(dir, "deleted-since-added") }], + }) + const vmConfig = JSON.parse(await readFile(join(prepared.dirs.runtime, "vm.json"), "utf8")) + expect(vmConfig.shares).toEqual([ + { tag: "workspace", path: prepared.projectDir, readOnly: false }, + { tag: "bundle", path: prepared.options.bundleDir, readOnly: true }, + { tag: "config", path: prepared.dirs.guestConfig, readOnly: true }, + { tag: "mnt-docs", path: docs, readOnly: false }, + ]) + expect(JSON.parse(await readFile(join(prepared.dirs.guestConfig, "mounts.json"), "utf8"))).toEqual({ + mounts: [{ tag: "mnt-docs", path: "/mnt/docs" }], + }) + expect(sandbox.mounts).toEqual([{ path: docs, guestPath: "/mnt/docs" }]) + + sandbox.stop() + await rm(dir, { recursive: true, force: true }) +}) + test("per-sandbox cpus and memory win over the provider defaults", async () => { const dir = await mkdtemp(join(tmpdir(), "codev-vz-")) const prepared = await preparedOptions(dir, FAKE_HELPER) diff --git a/packages/codev-sandbox/src/vz/provider.ts b/packages/codev-sandbox/src/vz/provider.ts index a1447dbfec..f6c18be052 100644 --- a/packages/codev-sandbox/src/vz/provider.ts +++ b/packages/codev-sandbox/src/vz/provider.ts @@ -7,6 +7,7 @@ import { forwardTcpToUnix } from "../bwrap/bridge" import { runCommand, unpackRootfs } from "../bwrap/rootfs" import { readImageMarker, stageAsset, writeImageMarker } from "../image" import type { SandboxAsset } from "../manifest" +import { resolveGuestMounts } from "../mounts" import { macosSandboxAvailability } from "../policy" import { createSandboxSocketDir } from "../socket" import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider" @@ -126,6 +127,21 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { await rm(dirs.runtime, { recursive: true, force: true }) await mkdir(dirs.runtime, { recursive: true }) await mkdir(dirs.guestConfig, { recursive: true }) + // Extra mounts, unlike projectDir, must not brick the start: a folder + // deleted since it was added is skipped, not fatal. + const accessible = [] + for (const mount of config.mounts ?? []) { + const found = await stat(mount.path).catch(() => null) + if (found?.isDirectory()) accessible.push(mount) + } + const guestMounts = resolveGuestMounts(accessible) + // Written every start, even when empty: guest-config persists across + // starts, and a stale list from the previous boot must not survive. The + // image's codev-init reads it and mounts each tag. + await writeFile( + join(dirs.guestConfig, "mounts.json"), + JSON.stringify({ mounts: guestMounts.map((mount) => ({ tag: mount.tag, path: mount.guestPath })) }), + ) // Sockets live outside the data dir: their absolute path must fit the // AF_UNIX limit, which the data dir alone nearly exhausts. const socketDir = await createSandboxSocketDir(dirs.base) @@ -167,6 +183,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { { tag: "workspace", path: config.projectDir, readOnly: false }, { tag: "bundle", path: options.bundleDir, readOnly: true }, { tag: "config", path: dirs.guestConfig, readOnly: true }, + ...guestMounts.map((mount) => ({ tag: mount.tag, path: mount.path, readOnly: false })), ], forwards: [ { direction: "host-to-guest", vsockPort: VZ_SERVER_VSOCK_PORT, socketPath: serverSocket }, @@ -252,6 +269,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { url, username: credentials.username, password: credentials.password, + mounts: guestMounts.map((mount) => ({ path: mount.path, guestPath: mount.guestPath })), stop: () => { // Ask the helper for a guest stop; escalate to kill if it hangs. child.stdin?.write('{"cmd":"stop"}\n') diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index 90cf67c965..6074923bf9 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -297,6 +297,30 @@ test("routeless egress still injects the guest config but starts no broker", asy await rm(dir, { recursive: true, force: true }) }) +test("start skips missing extra mounts; applyMounts hot-validates and translates", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider(await preparedOptions(dir)) + await provider.create(config) + const docs = join(dir, "docs") + await mkdir(docs, { recursive: true }) + + const sandbox = await provider.start({ ...config, mounts: [{ path: docs }, { path: join(dir, "missing") }] }) + // A folder deleted since it was added is skipped at start, never fatal. + expect(sandbox.mounts).toEqual([{ path: docs, guestPath: "/mnt/hostpath" }]) + + // The hot path is the validation gate: it throws instead of skipping. + expect(await provider.applyMounts?.({ ...config, mounts: [{ path: docs }] })).toEqual([ + { path: docs, guestPath: "/mnt/hostpath" }, + ]) + expect(provider.applyMounts?.({ ...config, mounts: [{ path: join(dir, "missing") }] })).rejects.toThrow( + "folder does not exist", + ) + + sandbox.stop() + await provider.destroy(config) + 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)) diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index 2a8f597356..226c8bcd0a 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -14,7 +14,13 @@ import { readImageMarker, stageAsset, writeImageMarker } from "../image" import { wslFirewallScript } from "./firewall" import type { SandboxAsset } from "../manifest" import { windowsBuildNumber, windowsSandboxAvailability } from "../policy" -import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider" +import type { + ResolvedSandboxMount, + SandboxAvailability, + SandboxConfig, + SandboxJobOptions, + SandboxProvider, +} from "../provider" import { parseDistroList, reserveLoopbackPort, runWsl, wslEnvSpec } from "./cli" // Guest-side paths. The bundle mount point and the launcher are baked into the @@ -176,6 +182,18 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider 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}`) + // Extra mounts need no guest work at all: automount already exposes the + // fixed Windows drives inside the distro, so resolving a mount is pure + // path translation. Unlike projectDir a broken one is skipped, not + // fatal — a deleted extra folder must not brick the start. + const mounts: ResolvedSandboxMount[] = [] + for (const mount of config.mounts ?? []) { + const found = await stat(mount.path).catch(() => null) + if (!found?.isDirectory()) continue + const translated = await guestPath(distro, mount.path).catch(() => null) + if (translated) mounts.push({ path: mount.path, guestPath: translated }) + } + 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. @@ -300,6 +318,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider url, username: credentials.username, password: credentials.password, + mounts, stop: () => { child.kill() // Killing wsl.exe ends the command but leaves the distro's VM running; @@ -324,6 +343,22 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider await discard(config) await prepare(config, opts) }, + + // Hot-applies the mount list to the running distro. All wsl.exe + // invocations share the distro's mount namespace and the drives are + // already automounted, so translation is the only work. Throws on an + // unusable path: this runs before the controller persists the config and + // doubles as the hot path's validation gate. + async applyMounts(config) { + const distro = wslDistroName(config.name) + const resolved: ResolvedSandboxMount[] = [] + for (const mount of config.mounts ?? []) { + const found = await stat(mount.path).catch(() => null) + if (!found?.isDirectory()) throw new Error(`folder does not exist: ${mount.path}`) + resolved.push({ path: mount.path, guestPath: await guestPath(distro, mount.path) }) + } + return resolved + }, } }