Skip to content

Enable FUSE in workerd's local-dev Docker container client#6596

Open
Ben2W wants to merge 1 commit intocloudflare:mainfrom
Ben2W:fuse-local-dev-support
Open

Enable FUSE in workerd's local-dev Docker container client#6596
Ben2W wants to merge 1 commit intocloudflare:mainfrom
Ben2W:fuse-local-dev-support

Conversation

@Ben2W
Copy link
Copy Markdown

@Ben2W Ben2W commented Apr 16, 2026

Closes #5609.

Problem

wrangler dev (via miniflare) doesn't support FUSE in locally-spawned Cloudflare Containers, even though Workers using FUSE run fine in production. This is because ContainerClient::createContainer() POSTs to the user's local Docker daemon's /containers/create endpoint without the HostConfig fields that make /dev/fuse usable inside the container.

This PR sets three HostConfig fields on the user's app container: CapAdd=[SYS_ADMIN], Devices=[/dev/fuse], SecurityOpt=[apparmor:unconfined]. Also adds missing $Json.name annotations to DeviceMapping in docker-api.capnp

I created a reproduction repo that demonstrates using this elevated HostConfig fixes the FUSE mount when running wrangler dev: https://github.com/Ben2W/workerd-fuse-local-repro

Notes

CAP_SYS_ADMIN elevates the container's permissions — it covers the mount() syscall FUSE needs, alongside a range of other privileged kernel operations, and has historically been the pre-condition for container-escape CVEs (CVE-2022-0492, CVE-2022-0185). The blast radius here is narrow: this code path only runs during wrangler dev on a developer's own machine, against their own Docker daemon, with an image they've chosen. If this is still too coarse for an unconditional default, I'm happy to pivot to an opt-in design similar to what was pitched in #5609.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 16, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@Ben2W Ben2W force-pushed the fuse-local-dev-support branch from 38469d2 to b7e40b2 Compare April 16, 2026 20:39
Comment on lines 34 to 38
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");
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docker's API uses PascalCase keys for these fields (PathOnHost / PathInContainer / CgroupPermissions). Without these $Json.name annotations, capnp's JSON codec emits the source field names in camelCase, which Docker silently ignores

@Ben2W Ben2W force-pushed the fuse-local-dev-support branch from b7e40b2 to 5b7d5cc Compare April 17, 2026 03:35
@Ben2W
Copy link
Copy Markdown
Author

Ben2W commented Apr 17, 2026

I have read the CLA Document and I hereby sign the CLA

@Ben2W
Copy link
Copy Markdown
Author

Ben2W commented Apr 17, 2026

recheck

github-actions Bot added a commit that referenced this pull request Apr 17, 2026
Ben2W added a commit to Ben2W/workerd-fuse-local-repro that referenced this pull request Apr 17, 2026
The reproduction's Docker proxy previously injected Privileged=true alongside
CapAdd/Devices/SecurityOpt. Privileged was redundant in practice (it masked
whether the other three were doing anything), and made it look like the
proxy and the workerd PR were doing different things.

Narrowed the proxy to only the three HostConfig fields that the upstream
PR (cloudflare/workerd#6596) enables:
CapAdd=[SYS_ADMIN], Devices=[/dev/fuse], SecurityOpt=[apparmor:unconfined].
All 3 vitest scenarios still pass. The proxy and the patched-workerd now
both make FUSE work with the exact same minimum capability set.
@Ben2W Ben2W marked this pull request as ready for review April 17, 2026 04:28
@Ben2W Ben2W requested review from a team as code owners April 17, 2026 04:28
@gpanders
Copy link
Copy Markdown
Member

/bonk review

I think this is likely fine so long as it doesn't require running with sudo.

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution! The docker-api.capnp fix is clearly correct and needed. The FUSE enablement logic works but I have a few concerns about the scope and unconditional nature of the changes.

Summary:

  • The $Json.name annotation fix on DeviceMapping is a genuine bug fix — without it the fields serialize as camelCase and Docker silently ignores them. Good catch.
  • Unconditionally adding SYS_ADMIN + /dev/fuse + apparmor:unconfined to every container is a broad change. Most containers don't need FUSE, and this widens the attack surface for all of them. Even in local dev, this adds capabilities that could mask bugs or create surprising behavior for users who aren't using FUSE.
  • The apparmor:unconfined setting is Linux-specific. On macOS (Docker Desktop with a Linux VM), AppArmor typically isn't active, so this is harmless but unnecessary. Not a blocker, but worth noting.

See inline comments for specifics.

Comment on lines +1701 to +1716
// 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");
}
{
auto securityOpt = hostConfig.initSecurityOpt(1);
securityOpt.set(0, "apparmor:unconfined");
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unconditionally grants SYS_ADMIN, maps /dev/fuse, and disables AppArmor for every container created via local dev, regardless of whether the user's workload uses FUSE. CAP_SYS_ADMIN is one of the most powerful Linux capabilities — it covers mount(), setns(), unshare(), bpf(), and more. Granting it by default to all local containers when only a small subset need FUSE seems overly broad.

A few options to consider:

  1. Opt-in flag: Gate this behind a configuration option (the approach mentioned in 🐛 Bug Report — Runtime APIs: No privileged mode option for Containers local development #5609). Users who need FUSE explicitly enable it.
  2. Check for /dev/fuse on the host: Only add the device mapping and capabilities if /dev/fuse actually exists on the host. This is a lighter heuristic.
  3. At minimum, add a comment explaining the security trade-off so future readers understand why every local container gets SYS_ADMIN.

I'd defer to the maintainers on which approach they prefer, but option 1 seems safest for a first pass — it avoids surprising users who don't need FUSE with elevated container privileges.

}
{
auto securityOpt = hostConfig.initSecurityOpt(1);
securityOpt.set(0, "apparmor:unconfined");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: apparmor:unconfined is a no-op on macOS (Docker Desktop runs a Linux VM that may or may not have AppArmor). Not a problem — Docker just ignores unknown security options — but worth a brief comment noting it's Linux-specific, similar to how gatewayForPlatform (line 873) documents its macOS behavior.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 67aeb3b

// Docker doesn't grant FUSE access by default — enable the minimum permissions for it.
{
auto capAdd = hostConfig.initCapAdd(1);
capAdd.set(0, "SYS_ADMIN");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the sidecar container (line 1976) already uses initCapAdd(1) for NET_ADMIN, this follows the existing pattern well. However, if FUSE support is eventually gated behind an opt-in, this block should be conditional. Even if it stays unconditional, consider adding a brief rationale comment explaining why SYS_ADMIN specifically (i.e., FUSE needs the mount() syscall) so future readers don't mistake it for an oversight.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 17, 2026

Review posted successfully. Here's a summary of the feedback I left:

docker-api.capnp fix (lines 35-37): The $Json.name annotation additions on DeviceMapping fields are a clear bug fix. Without them, Cap'n Proto's JSON codec serializes as camelCase (pathOnHost), which Docker silently ignores since it expects PascalCase (PathOnHost).

FUSE enablement in container-client.c++ (lines 1701-1716): The main concern is that SYS_ADMIN, /dev/fuse device mapping, and apparmor:unconfined are applied unconditionally to every container created in local dev, not just containers that use FUSE. I suggested:

  1. An opt-in configuration flag (safest approach)
  2. A heuristic like checking for /dev/fuse on the host
  3. At minimum, better documentation of the security trade-off

I also noted that apparmor:unconfined is Linux-specific (no-op on macOS Docker Desktop) and suggested a clarifying comment similar to the existing gatewayForPlatform pattern.

github run

@Ben2W Ben2W force-pushed the fuse-local-dev-support branch 2 times, most recently from 2d5d9d8 to 67aeb3b Compare April 17, 2026 19:12
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 <bewerner23@gmail.com>
@Ben2W Ben2W force-pushed the fuse-local-dev-support branch from 67aeb3b to c12a5d6 Compare April 23, 2026 22:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Bug Report — Runtime APIs: No privileged mode option for Containers local development

2 participants