From c12a5d6f2842f6d1887cfe2f7caf231ddcd68652 Mon Sep 17 00:00:00 2001 From: Ben Werner Date: Thu, 16 Apr 2026 12:49:51 -0700 Subject: [PATCH] Enable FUSE in local-dev Docker containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In ContainerClient::createContainer(), grant the minimum capabilities needed so a FUSE mount inside the user's container succeeds: - CapAdd: ["SYS_ADMIN"] (for the mount() syscall) - Devices: [/dev/fuse] (so the kernel has something to open) - SecurityOpt: ["apparmor:unconfined"] (bypasses the default docker apparmor profile's mount block) No Privileged=true. Mirrors what Cloudflare's production container runtime effectively provides for FUSE workers, without granting all Linux capabilities or full /dev passthrough. Only affects workerd's local Docker code path; production is unaffected. Also adds missing $Json.name annotations to docker_api::DeviceMapping fields in docker-api.capnp so they serialize as PathOnHost / PathInContainer / CgroupPermissions to match the Docker API. This is load-bearing for the Devices bind above — without it, the entry serializes as camelCase and Docker silently ignores it. Before this PR no call site populated Devices, so the missing annotations were latent. Follows the existing pattern of createSidecarContainer() which already sets CapAdd=[NET_ADMIN] for its own need at container-client.c++:1958. End-to-end reproduction (libc mount("fuse", ...) syscall succeeding inside the container, with a wire-level log confirming PathOnHost etc. reach Docker correctly) at https://github.com/Ben2W/workerd-fuse-local-repro Signed-off-by: Ben Werner --- src/workerd/server/container-client.c++ | 18 ++++++++++++++++++ src/workerd/server/docker-api.capnp | 6 +++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/workerd/server/container-client.c++ b/src/workerd/server/container-client.c++ index b577ea16605..539327c0365 100644 --- a/src/workerd/server/container-client.c++ +++ b/src/workerd/server/container-client.c++ @@ -1698,6 +1698,24 @@ kj::Promise ContainerClient::createContainer(kj::StringPtr effectiveImage, } } + // Docker doesn't grant FUSE access by default — enable the minimum permissions for it. + { + auto capAdd = hostConfig.initCapAdd(1); + capAdd.set(0, "SYS_ADMIN"); + } + { + auto devices = hostConfig.initDevices(1); + auto fuseDev = devices[0]; + fuseDev.setPathOnHost("/dev/fuse"); + fuseDev.setPathInContainer("/dev/fuse"); + fuseDev.setCgroupPermissions("rwm"); + } + { + // Linux-only: no-op on hosts without AppArmor (e.g. macOS). + auto securityOpt = hostConfig.initSecurityOpt(1); + securityOpt.set(0, "apparmor:unconfined"); + } + auto response = co_await dockerApiRequest(network, kj::str(dockerPath), kj::HttpMethod::POST, kj::str("/containers/create?name=", containerName), codec.encode(jsonRoot)); diff --git a/src/workerd/server/docker-api.capnp b/src/workerd/server/docker-api.capnp index 9603f7b0639..b96dd2abd17 100644 --- a/src/workerd/server/docker-api.capnp +++ b/src/workerd/server/docker-api.capnp @@ -32,9 +32,9 @@ struct Docker { } struct DeviceMapping { - pathOnHost @0 :Text; - pathInContainer @1 :Text; - cgroupPermissions @2 :Text; + pathOnHost @0 :Text $Json.name("PathOnHost"); + pathInContainer @1 :Text $Json.name("PathInContainer"); + cgroupPermissions @2 :Text $Json.name("CgroupPermissions"); } struct DeviceRequest {