diff --git a/CHANGELOG.md b/CHANGELOG.md index 09dc4a8883..5ecc8bb0b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Sandboxes now start on Linux** — every sandbox on Linux failed immediately + with `Can't mkdir /opt/codev-bridge: Read-only file system`. The guest's + `/opt` comes from the read-only image, so the sandbox could not create its + own mount point inside it; the internal bridge directory moved to `/codev-bridge`. + No image download or re-create is needed — existing sandboxes start as they are. - **New session inside a sandbox now works** — a sandbox had no project of its own in the app, so **New session** had nothing to open there and quietly started the session on the local server instead: the sandbox looked selected diff --git a/packages/codev-sandbox/src/bwrap/args.test.ts b/packages/codev-sandbox/src/bwrap/args.test.ts index 32d8acd369..7ab4b44c99 100644 --- a/packages/codev-sandbox/src/bwrap/args.test.ts +++ b/packages/codev-sandbox/src/bwrap/args.test.ts @@ -44,13 +44,27 @@ test("mounts the workspace rw and the bundle/bridge ro after the rootfs binds", const joined = buildBwrapArgs(spec).join(" ") expect(joined).toContain("--bind /host/project /workspace") expect(joined).toContain("--ro-bind /host/bundle /opt/codev") - expect(joined).toContain("--ro-bind /data/default/bridge /opt/codev-bridge") + expect(joined).toContain("--ro-bind /data/default/bridge /codev-bridge") expect(joined).toContain("--bind /data/default/runtime /run/codev") expect(joined).toContain("--bind /data/default/home /home/codev") expect(joined.indexOf("--bind /host/project")).toBeGreaterThan(joined.indexOf("--ro-bind /data/default/rootfs/usr")) expect(joined).toContain(`--chdir ${GUEST_WORKSPACE}`) }) +// bwrap creates missing mount points, but only where it can write: its own +// tmpfs root, never inside a read-only rootfs bind. A destination nested under +// a bound rootfs entry must therefore already exist in the image — which the +// image only guarantees for /opt/codev. GUEST_BRIDGE lived at /opt/codev-bridge +// and failed every real start with EROFS; the integration test missed it +// because its host-rootfs allowlist excludes /opt. +test("never nests a mount destination inside a read-only rootfs bind", () => { + const args = buildBwrapArgs({ ...spec, rootfsEntries: [{ name: "opt", symlinkTarget: null }] }) + const destinations = args.flatMap((arg, index) => + (arg === "--bind" || arg === "--ro-bind") && !args[index + 1].startsWith(spec.rootfsDir) ? [args[index + 2]] : [], + ) + expect(destinations.filter((destination) => destination.startsWith("/opt/"))).toEqual(["/opt/codev"]) +}) + 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 27173b2ca3..9b30666a14 100644 --- a/packages/codev-sandbox/src/bwrap/args.ts +++ b/packages/codev-sandbox/src/bwrap/args.ts @@ -2,7 +2,11 @@ import { join } from "node:path" export const GUEST_WORKSPACE = "/workspace" export const GUEST_BUNDLE = "/opt/codev" -export const GUEST_BRIDGE = "/opt/codev-bridge" +// Top level, not under /opt: the image's own /opt is bind-mounted read-only +// from the rootfs, so bwrap cannot create a mount point inside it. Only paths +// the image already provides (/opt/codev) or that bwrap creates on its tmpfs +// root (this one, /run/codev, /workspace) can be mounted over. +export const GUEST_BRIDGE = "/codev-bridge" export const GUEST_RUNTIME = "/run/codev" export const GUEST_HOME = "/home/codev" // Fixed ports inside the guest's private network namespace — no conflicts are