From 1c94826f0bd3fb5f30b2281c3bab1720743784ce Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Thu, 13 Aug 2026 13:30:50 +0700 Subject: [PATCH] fix(sandbox): mount the guest bridge outside the read-only /opt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bwrap sandbox failed to start with "Can't mkdir /opt/codev-bridge: Read-only file system". bwrap creates missing mount points only where it can write — its own tmpfs root, never inside a read-only bind — and /opt comes from the image rootfs, which ships /opt/codev but no bridge dir. Move GUEST_BRIDGE to a top-level /codev-bridge, so bwrap creates it on the tmpfs root alongside /run/codev and /workspace. Host-side only: existing sandboxes start as they are, with no image republish or re-create. The integration test missed this because its host-rootfs allowlist excludes /opt, so the destination was always created on the tmpfs root; cover the invariant directly instead. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +++++ packages/codev-sandbox/src/bwrap/args.test.ts | 16 +++++++++++++++- packages/codev-sandbox/src/bwrap/args.ts | 6 +++++- 3 files changed, 25 insertions(+), 2 deletions(-) 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