Skip to content

Computerd passthrough - #197

Open
aron-cf wants to merge 31 commits into
mainfrom
computerd-passthrough
Open

aron-cf wants to merge 31 commits into
mainfrom
computerd-passthrough

Conversation

@aron-cf

@aron-cf aron-cf commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

A local-only path avoids syncing dependency trees into the durable object, but FUSE still adds round trips for file data and metadata. This change moves computerd to libfuse 3 through a vendored binding so Linux 6.9+ can serve local-only file data through kernel FUSE passthrough. Synced paths continue through computerd.

With MOUNT_IGNORE configured, eligible local-only directories also become native roots by default when passthrough and mount permissions are available. A bind mount serves both data and metadata from the container's disk. The mount helper resolves its source and target beneath pinned directory descriptors so workspace symlinks cannot redirect a privileged bind outside the mount. A refused bind falls back to FUSE; COMPUTERD_FUSE_NATIVE_ROOTS=0 disables native roots. A removal guard detaches registered roots when commands launched by computerd remove or rename them.

flowchart LR
  app[container process] --> kernel[Linux VFS]
  kernel -- synced path --> computerd --> vfs[workspace state and sync]
  kernel -- local-only metadata without a native root --> computerd
  kernel -- passthrough file data or native root --> disk[container disk]
Loading

The binary needs system libfuse 3.17 or newer (Debian trixie), plus /dev/fuse. Native roots also need negotiated passthrough, mount permissions, and kernel support for the mount helper. Performance measurements and their hardware limits are in the performance docs. Those native-root measurements predate the descriptor-confined helper and do not measure mount setup time.

To verify locally, build the binary with COMPUTERD_BIN_TARGETS=linux-arm64 npm run build:bin --workspace @cloudflare/computerd on an arm64 machine, then run npx vitest run src/fuse/native-roots.fuse.test.ts src/fuse/passthrough.fuse.test.ts src/exec/runner.fuse.test.ts from packages/computerd with privileged Docker and /dev/fuse available. The unit suite and real-FUSE regressions cover restored symlinks, fallback, mount cleanup, and ordinary operations. Native x86_64 hardware and Cloudflare Container behavior remain unverified; do not enable native roots for production until those checks and security review are complete. Do not create unrelated mounts under /workspace: the current cleanup treats every mount there as a native root.


Devin Review

@aron-cf aron-cf added the allow-pr Allow a PR to remain open. label Oct 5, 2026
@changeset-bot

changeset-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

馃 Changeset detected

Latest commit: de6822e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@cloudflare/computerd Minor
@cloudflare/dofs Minor
@cloudflare/computer-rpc Minor
@cloudflare/computer Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 3 potential issues.

Devin Review

Comment on lines +753 to +763
let entry = backingIds.get(key);
if (entry === undefined) {
const id = registerBackingFile(registrar, handle);
if (id === undefined) return fh;
entry = { id, refs: 0 };
backingIds.set(key, entry);
}
entry.refs += 1;
handle.backingKey = key;
passthroughOpens += 1;
return { fd: fh, backingId: entry.id };

@devin-ai-integration devin-ai-integration Bot Oct 5, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃敶 Shared backing file breaks mixed-mode opens

When two local-only handles use different flags, registerOpen shares the first handle's backing ID. The kernel then uses that backing file for both handles, so a writable handle opened after a read-only one cannot write.

Learn more

A backing ID refers to the descriptor passed to backingOpen, including its access and append flags. backingIds is keyed only by device and inode, so later opens reuse the first descriptor's ID even when their requested access differs. The open handler preserves each open's flags on its own descriptor, but the kernel's passthrough data operations use the shared backing ID instead. The existing concurrent-open tests use compatible modes and do not catch this mismatch.

Example: Open node_modules/data for reading, keep it open, then open it for writing. The second open succeeds but reuses the read-only backing ID; writing through that handle fails instead of modifying the file.

Recommended fix: Ensure backing registrations used by an inode support every open's requested access and append semantics. If a compatible backing ID cannot be shared safely, fall back to daemon-served I/O for the incompatible handle; exercise read-only/write-only and append/non-append simultaneous opens under real FUSE.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

Comment on lines +217 to +219
const previous = cacheMarks.get(path);
cacheMarks.set(path, mark);
return previous === mark;

@devin-ai-integration devin-ai-integration Bot Oct 5, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃敶 Renamed files can retain stale cached bytes

After rename replaces a synced file, resolveKeepCache can reuse the destination's old mark. When both files share size and mtime, reopening the destination retains the displaced file's cached bytes.

Learn more

cacheMarks records only the previous mtime and size for each pathname. rename replaces a destination without clearing its mark, and unlink also leaves a mark behind. The kernel's page cache can still contain pages from the old inode at that pathname, while the next open sets keepCache if the replacement happens to have the same metadata. A changed inode must force cache invalidation even when size and mtime match.

Example: Two synced files /workspace/a and /workspace/b both have size 4 and mtime 1000, but contain new! and old!. After /workspace/b is read and /workspace/a is renamed onto it, reopening /workspace/b can return old!.

Recommended fix: Track inode identity as part of the mark and invalidate marks on rename and unlink, including directory subtree renames. Add a real-FUSE regression with equal-size/equal-mtime replacement after warming the destination's page cache.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

Comment thread docs/README.md
@pkg-pr-new

pkg-pr-new Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@197

commit: d562de8

Replaces fuse-native (libfuse 2.9) with a vendored, patched fuse-napi
2.3.1 (MIT) built against libfuse 3, which is what can negotiate
FOPEN_PASSTHROUGH. Backward compatibility with the 2.9 binding is not
kept; there is one mount backend.

Binding (vendor/fuse-napi, changes in PATCHES.md):

- backingOpen(fd) / backingClose(id), the ioctl libfuse only exposes
  through its low-level API
- backingId on the open/create result, written to info->backing_id
- maxBackingStackDepth init config and Fuse.CAP_PASSTHROUGH
- a backing id combined with keepCache or directIO is refused, because
  the kernel rejects that pairing with an opaque EIO from open()

Mount options (options.ts) split in two, because libfuse 3 moved some of
these into the init config:

- buildFuseInitConfig() carries max_write, now an init field
- big_writes is gone; libfuse 3 removed it and batching is the default
- auto_cache and kernel_cache are gone, and are refused in EXTRA_OPTS

Driver:

- the _fuseOptions() monkey patch is deleted; options go through the
  constructor and the init callback
- keepCache is decided per open for VFS-backed files, applying
  auto_cache's own rule (reuse when mtime and size are unchanged since
  the last open) because libfuse sets keep_cache after the open callback
  returns and does not check for passthrough
- a created or recreated path never inherits a previous cache mark

Verified on kernel 6.18 with libfuse 3.17.2: reads and writes through a
passthrough handle never reached the JavaScript handlers, including two
concurrent opens sharing one refcounted backing id.
Follows the libfuse 3 port. The option string no longer carries
big_writes (removed upstream), max_write (an init config field now), or
auto_cache / kernel_cache (incompatible with passthrough, and the
keep-cache decision moved into the driver).

- options.test.ts covers the new default profile, max_write arriving
  through buildFuseInitConfig, the backing stack depth request, and that
  a typo in EXTRA_OPTS naming a removed option is stripped rather than
  failing the mount
- driver.test.ts reads open/create handles through a helper, since those
  now reply with a file-info object, and pins that a freshly created
  file never reuses the page cache
`build:bin` still read the fuse-native prebuild and its bundled
libfuse.so.2, both gone since the move to the vendored fuse-napi
binding, so it failed before producing anything.

The addon is now compiled from the vendored source inside a
node:22-trixie-slim container for linux/amd64. A container rather than
the build host because the CI runners carry libfuse 3.14, older than
the 3.17 that passthrough needs, and a developer machine may not be
x64. The result is cached under dist/sea and keyed on the addon
sources, since an emulated compile takes minutes. COMPUTERD_FUSE_ADDON
points at a prebuilt fuse.node to skip the container entirely.

The binary no longer embeds a copy of libfuse or prepends
LD_LIBRARY_PATH. The addon links the system libfuse 3, so the image
that runs it needs Debian trixie's fuse3 package or an equivalent; a
missing library now fails with a message that says so. The macOS
target is gone, since the vendored binding is only wired for Linux.

The container is run detached and then waited on. An attached
`docker run` against a remote daemon can lose its stream and return
early, which looked like a build that produced no addon.

The test harness image moves to debian:trixie-slim with fuse3 only, so
the harness and the runner's real-FUSE test boot the new binary.
fuse-napi takes typed mount options, not a raw -o list.
buildFuseOptionString becomes buildFuseMountOptions.
COMPUTERD_FUSE_EXTRA_OPTS keeps the libfuse spelling operators know and
is translated entry by entry. ac_attr_timeout is gone: it only tunes
auto_cache, and the binding refuses it without auto_cache. A new test
runs the profile through the binding's validator.

The binding exposed neither use_ino nor negative_timeout, so the
vendored copy gains both. use_ino is a fuse_config field in FUSE 3 that
fuse_new() rejects on the command line. PATCHES.md records the
change.

toBindingOps adapts the op table. It drops `error`, `init` (mutually
exclusive with initWithConfig), and getBufferStats, all of which the
binding refuses. It also swaps utimens for utimensWithTimespec. Plain
utimens answers EOPNOTSUPP for UTIME_NOW and UTIME_OMIT, which is what
a bare `touch` sends. Omitted times are now read back and kept so
`touch -a` and `touch -m` work.

fsync and fsyncdir take (path, datasync, fd) in fuse-napi. With the old
order the boolean landed where the handle should be, and fsync on a
local-only file leaked into the VFS. listxattr must return an array of
names rather than a buffer.

libfuse 3 negotiates atomic O_TRUNC by default, so `: > f` arrives as
open(O_TRUNC) with no separate truncate. open now honors the flag; the
old bytes survived before.
An open or create of a local-only file now registers the file with the
kernel and replies with its backing id. The kernel then serves reads,
writes, and mmap for that handle straight from the backing file, and
computerd is out of the data path. Writing 8 MiB and reading it back
twice under an ignored path cost the daemon 16 writes and 64 reads
before; it now costs none.

The kernel allows one backing file per inode, and a second registration
of the same file does not fail. It returns a new id, and the open that
uses it fails with EIO. So the local-only layer keeps one id per file,
keyed on dev:ino so a hard link shares it, counts the opens using it,
and closes it on the last release.

If registration fails, the file is served by computerd as before and
the first failure is logged with a hint for the usual causes: EPERM
without CAP_SYS_ADMIN, ELOOP on a stacked backing store, and ENOTSUP
when the kernel did not offer passthrough. Fallbacks are counted
rather than logged after that. A passthrough reply never carries
keepCache or directIO, which the kernel refuses alongside a backing id.

mountFuse passes the layer a late-bound registrar. The op table has to
exist before the Fuse instance, so the closure reads the instance
assigned after construction and answers ENOTSUP until init confirms the
capability. The capability is only requested when there are local-only
paths, so a mount without MOUNT_IGNORE negotiates what it did before,
and COMPUTERD_FUSE_PASSTHROUGH=0 turns it off for debugging.
`fastPaths.passthrough` was a constant false with a reason naming
libfuse 2.9. It now reports what the mount is doing, read on each
request: `passthrough` is true once the kernel is serving local-only
file data, and `passthroughOpens` and `passthroughFallbacks` count how
opens were served.

When passthrough is not active, `passthroughReason` says which of the
cases applies, because each has a different fix: no local-only paths
configured, no kernel FUSE mount running, turned off with
COMPUTERD_FUSE_PASSTHROUGH, a kernel that never offered the capability,
a kernel that offered it but refused every registration, and a mount
that negotiated it but has not opened a local-only file yet.

mountFuse exposes getPassthroughStatus on FuseMount for this, and the
info object reads it through a getter. PASSTHROUGH_UNAVAILABLE_REASON
is gone, along with the comments that still described the fuse-native
behavior.
The unit tests drive the backing-id table through a fake registrar so
they cannot show the kernel's side. passthrough.fuse.test.ts boots the
binary in a privileged container, as runner.fuse.test.ts does, and
counts the daemon's read and write callbacks with the op tracer.

It checks three things. Writing 4 MiB under an ignored path and reading
it back twice costs the daemon no reads or writes, and
/__computerd/info reports passthrough active. Concurrent opens of one
file, and of a hard link to it, all succeed; a second backing id for
one inode would make the kernel fail the open with EIO. And a synced
file still keeps its page cache across opens now that auto_cache is
gone from the mount options.

The passthrough cases skip on a kernel that does not offer the
capability, since that is a property of the host. run-computerd.sh now
forwards MOUNT_IGNORE, MOUNT_IGNORE_PATH, and COMPUTERD_FUSE_* into the
container so a test can set them.
computerd now links the system libfuse 3 and needs 3.17 or newer,
which means Debian trixie. The scripts and harnesses that run the
freshly built binary drop libfuse2t64 and keep fuse3, and the soak
harness image is renamed to match.

The examples pin the published 0.4.1 image, which links libfuse 2, and
the Version Packages pull request moves those pins to the new release
in one step. Until then each example keeps libfuse2t64 next to fuse3,
with a comment saying when it can go. The egress and mcp examples move
from bookworm, whose libfuse 3.14 is too old for the new binary, to
trixie. Both images were checked with the 0.4.1 binary and with a fresh
build mounted over it.

AGENTS.md describes the vendored addon, the libfuse 3 headers it builds
against, the Docker step in build:bin, and the build:types step that
--ignore-scripts skips, in place of the fuse-native and arm64 notes.
The FUSE debugging skill gains a recipe for checking that passthrough
is engaged.
The benchmarks ran the x64 binary, which on an arm64 machine means
emulation and timings that say nothing about the mount. build:bin now
takes COMPUTERD_BIN_TARGETS, so `linux-arm64` builds a native binary
alongside or instead of the published linux-x64 one. Building one
target no longer deletes another's binary.

The npm bench gains a TARGETS list, a `large` scenario (TypeScript,
Vite, webpack, ESLint, React; about 5,400 files and 88 MB), and a timed
read of every installed file after each install. An install is mostly
metadata, while the read-back is the data path that passthrough
changes. IGNORE_NODE_MODULES=1 makes each run's node_modules local-only
while its package.json stays synced, which is how MOUNT_IGNORE is meant
to be used, and PLATFORM picks the Docker platform.

run-fs-bench.sh gains LOCAL_ONLY=1, which runs the whole bench under a
local-only directory. Both harnesses forward COMPUTERD_FUSE_PASSTHROUGH,
so the same run can be repeated with passthrough off.
The docs described a binary that embedded libfuse 2.9 and a
passthrough that could not exist. They now describe the libfuse 3
build: the runtime needs libfuse 3.17 or newer, which is Debian trixie,
and no longer needs libfuse2t64 from the release after 0.4.1; the
snippets that still pin 0.4.1 say so. No macOS binary is published.

The local-only section of the computerd README replaces the libfuse 2.9
fastPaths example with the live block, and a table of every
passthroughReason with its fix. docs/19_performance.md drops the
"track the synced mount" guidance for local-only paths and gains
fs-bench and npm install numbers for the synced mount, local-only paths
with and without passthrough, and raw disk. Large-file I/O under a
local-only path runs at 0.9 to 1.3 times raw disk, against 3 to 10
times on the synced mount, while small-file metadata still goes through
computerd. The numbers come from a local arm64 virtual machine, and the
page says so and how to reproduce them. The Cloudflare Containers cells
stay empty until that install is run there.

The computerd README also covers the runtime and build requirements,
COMPUTERD_FUSE_PASSTHROUGH, the passthrough test suite, and the arm64
build target. bench-results.md marks its auto_cache section as a record
of the libfuse 2.9 build. A changeset covers the libfuse 3 requirement,
the dropped macOS binary, passthrough, and the three behavior changes
on the synced mount.
@aron-cf
aron-cf force-pushed the computerd-passthrough branch from a56651e to fa693f8 Compare October 14, 2026 09:56
The harness always ran a linux/amd64 container, so an arm64 binary
could not be tested with it and an x64 one ran under emulation on an
arm64 machine. COMPUTERD_HARNESS_PLATFORM now picks the platform, and
the cached image is tagged per platform so the two do not overwrite
each other.

COMPUTERD_HARNESS_IMAGE boots a prepared image instead of the stock
one, for instance one committed from a running container to stand in
for a snapshot restore. MOUNT_NATIVE and COMPUTERD_DB are forwarded
into the container along with the variables already passed through.
FUSE passthrough sends a local-only file's data straight to the
kernel, but every lookup, stat, create, and listing under node_modules
still reaches computerd's event loop, and for a dependency tree that
is most of the work. MOUNT_NATIVE names a subset of the MOUNT_IGNORE
patterns whose directories are bind-mounted onto the mount instead,
so nothing under them reaches computerd at all.

The source of each bind mount is the directory the local-only layer
already serves the path from. That is what keeps the feature small: a
mount only changes how fast a path is served, never what it contains,
so a mount that lands late, fails, or is detached needs no handling
beyond leaving the path to the FUSE layer. /proc/self/mountinfo is the
only record of what is mounted, since anything under the mount point
other than the FUSE mount itself was mounted here, and a lazy detach
is always safe.

There is no watcher. A native root's parent is synced, so the mkdir
that creates one reaches the local-only layer, which reports it after
replying; computerd then mounts it from a child process, because
resolving a path under the mount from the event loop would wait on a
request that same loop has to answer. A lookup that finds a root on
disk but not mounted, as after a snapshot restore once the host has
pushed the synced parent back, mounts it too, and startup mounts roots
already present. A refused root is retried only when created again,
and one detached on request is parked so a lookup cannot remount it
under the rm that follows. Renames of a synced ancestor need nothing:
the kernel moves the mount with the dentry.

Native roots require negotiated passthrough, which shares their
capability and kernel requirements and rules out a second page cache
for files opened through FUSE before the mount landed. The one
behavior change is that rmdir and rename of a root itself return
EBUSY, as for a Docker volume, since the kernel never forwards them;
POST /__computerd/native-roots/detach detaches roots on request.
/__computerd/info reports the state under fastPaths.nativeRoots, and
everything is detached before FUSE is unmounted on SIGTERM.

MOUNT_IGNORE sets gain matches(), which tests a single path against
the patterns rather than asking whether it lies under a match.
The fs and npm benches gain a NATIVE_ROOTS=1 knob, which lists the
local-only path they already use in MOUNT_NATIVE as well, so a run
measures work inside a bind-mounted native root. The fs bench prints
the mount and the native-root counters at the end, so a run shows it
measured what it meant to.

The npm bench deletes each run's directory before the next, and a
native root inside it is a mount point that rm cannot remove. It now
detaches native roots through the daemon first when they are on, which
is the same step a host has to take before removing one.

docs/19_performance.md gains a native-roots section with fs-bench and
npm install numbers beside the synced mount, local-only paths with
passthrough, and raw disk. Every scenario under a native root lands at
raw disk within noise, against 15 to 25 times raw for metadata-heavy
work with passthrough alone; the front-end toolchain installs in 1,365
ms against 1,336 ms on raw disk and 2,165 ms with passthrough. The
numbers come from a local arm64 virtual machine, and the page says so.
Brings in thirteen commits from main, among them the 0.6.0 release,
the lazy getWorkspace client, and the isolate JavaScript changes.

Two files conflicted. The linux-x64 image README takes main's 0.6.0
tag in its Dockerfile snippet and keeps this branch's trixie base
without libfuse2t64. package-lock.json is main's, regenerated with
`npm install --package-lock-only` to add the vendored fuse-napi in
place of fuse-native.
The docs and example Dockerfiles said computerd 0.4.1 and earlier link
libfuse 2, but 0.5.1 and 0.6.0 were released from main without the
libfuse 3 binding and link libfuse 2 as well. The prose now says 0.6.0
and earlier.

The Dockerfile comments explaining libfuse2t64 named the pinned
version, which the release process rewrites on every release, so they
went stale at the first bump. They now tie the package to the tag
being 0.6.0 or earlier and say to drop it once the tag is past that,
which stays true when the tag moves. The container-legacy example
installed it without saying why and gains the same comment.

The snippets in docs/README.md and the linux-x64 image README pin the
0.6.0 image on trixie but left libfuse2t64 out, so the binary they copy
would not have found its library. They install it, with the same
comment, until the pinned tag is a libfuse 3 release.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 2 new potential issues.

Devin Review

Comment thread packages/computerd/src/fuse/native-roots.ts
Comment on lines +280 to +288
detach: async (relativePath) => {
const all = owned();
const targets =
relativePath === undefined
? all
: all.filter((candidate) => candidate === targetOf(stripSlashes(relativePath)));
const detached = await detachTargets(targets);
for (const target of detached) parked.add(relOf(target));
return detached;

@devin-ai-integration devin-ai-integration Bot Oct 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃煛 Immediate detach leaves root mounted

After a new native directory is created, detach() can return before its pending bind mount completes. owned() then finds nothing, and the mount lands afterward; the caller's removal fails with EBUSY.

Learn more

Creation calls ensure(), which starts mountOne() without waiting for it; the bind mount may not yet appear in mountinfo. The detach endpoint calls detach() and returns its answer to the caller. If it runs before the bind mount appears, owned() reports no target, so nothing is parked and the pending mount proceeds. The caller can then receive { detached: [] } yet get EBUSY when removing the directory. See ensure and handleNativeRootsDetach.

Example: A command creates /workspace/app/node_modules, immediately requests { "path": "app/node_modules" }, then removes it. The detach request returns before the asynchronous mount --bind finishes; rm -rf hits the newly mounted root and fails.

Recommended fix: Park the requested root before awaiting pending work, wait for its in-flight mountOne to settle, then detach any resulting mount. Apply the same ordering to detach-all during shutdown.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

MOUNT_NATIVE made native roots an opt-in subset of MOUNT_IGNORE, to
keep the one behavior change, EBUSY when removing or renaming a mounted
directory itself, away from existing MOUNT_IGNORE deployments. There
are none yet, and a second list that has to repeat entries of the
first is configuration nobody wants to write. MOUNT_IGNORE now implies
native roots: every local-only directory whose parent is synced is
bind-mounted when passthrough is negotiated, and a file pattern never
is.

COMPUTERD_FUSE_NATIVE_ROOTS=0 turns them off, with the same spellings
as COMPUTERD_FUSE_PASSTHROUGH, for ruling them in or out while chasing
a problem. The passthrough suites set it, so they keep testing the
passthrough layer rather than a mount that may have landed first.

MOUNT_NATIVE, its validation, and the matches() helper it needed are
gone. /__computerd/info drops nativeRoots.patterns and reports the off
switch in nativeRoots.reason. The benches measure native roots by
default under LOCAL_ONLY and IGNORE_NODE_MODULES; the switch measures
passthrough alone.
rmdir and rename of a mount point fail with EBUSY in the kernel's VFS
before FUSE is consulted, so with native roots `rm -rf node_modules`
emptied the directory and then failed on it, and so did fs.rm, rimraf,
and anything else that removes or renames a dependency tree. computerd
never saw the call.

computerd now installs a seccomp filter on its JavaScript main thread,
the one every command is spawned from, so each command and everything
it starts inherits it. The filter pauses rmdir, the rename family, and
unlinkat with AT_REMOVEDIR, which BPF can tell apart from a file
deletion, and allows everything else. A thread in the vendored binding
reads the path, and if it names a native root, lazily detaches the
mount and lets the call continue; computerd then serves it through FUSE
like any local-only path, so rm succeeds and mv of a root gets the
EXDEV it gets without a mount. The detach is reported back so the root
is parked and a lookup cannot remount it under the removal.

The thread is started before the filter is installed, so it is never
filtered itself, and it never waits on the JavaScript thread, whose own
removals and renames are trapped too: paths are read through /proc and
process_vm_readv, checked against an in-memory table, and a root is
detached through a descriptor rather than its path under the FUSE
mount, whose walk could need a lookup that thread has to answer. It
answers every call with CONTINUE. This is a convenience rather than a
boundary; a path changed after it was read costs at most a needless
detach or the old EBUSY.

The filter is installed without no_new_privs, so setuid binaries keep
working; that needs CAP_SYS_ADMIN, which native roots need already. On
Cloudflare Containers PID 1 has it and seccomp user notification with
CONTINUE works. Each directory removal or rename costs about 15 碌s.
Processes computerd did not start still get EBUSY and can use the
detach endpoint, which the npm bench does. /__computerd/info reports
the guard as nativeRoots.transparentRemoval with a reason.
The native-roots suite runs commands through computerd's exec RPC, as
a host does, so the removal guard covers them, and checks the kernel's
view with docker exec as before. With the guard, rm -rf of a root and
of a directory holding roots succeeds, a program calling rmdir(2)
itself does too, renaming a root gets EXDEV so mv copies, and a
background process that outlives its command can still remove one.
Ordinary removals and renames are unaffected, and a removed root is
mounted again when created again.

The existing EBUSY case now names what it covers: processes computerd
did not start, which the guard cannot see, and which still need the
detach endpoint.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 4 new potential issues.

Devin Review

Comment on lines +284 to +289
for (size_t i = 0; i < g_roots_len; i++) {
if (strcmp(g_roots[i].path, path) == 0) {
fd = g_roots[i].fd;
owned = g_roots[i].path;
g_roots[i] = g_roots[--g_roots_len];
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃煛 Renamed native roots resist removal

After renaming a synced parent, rm -rf of its native root returns EBUSY. The guard still matches its old pathname, although the kernel moved the mount with the parent.

Learn more

Native roots are bind mounts under synced directories. Renaming a synced parent moves its child mount to the new pathname, as native-root tests verify. The guard records the path when registerWithGuard opens the root, but nothing updates that record after the parent moves. An rm -rf using the new path misses the guard table and gets EBUSY at the mount point.

Example: Mount /workspace/pkgs/app/node_modules, rename /workspace/pkgs/app to /workspace/pkgs/web, then run rm -rf /workspace/pkgs/web/node_modules through shell exec. The bind mount moves to web, but the guard still knows app; removal fails.

Recommended fix: Resolve registered mounts by their current location or update guard entries when an ancestor is renamed. Cover rename followed by root removal in the real-FUSE suite.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

Comment on lines +349 to +350
if (read_target_string((pid_t) req->pid, paths[i], raw, sizeof(raw)) != 0) continue;
if (resolve((pid_t) req->pid, dirs[i], raw, absolute, sizeof(absolute)) != 0) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃煛 Changed-user commands cannot remove native roots

When a child changes UID without CAP_SYS_PTRACE, process_vm_readv cannot read its removal path. The guard skips detachment, so rm -rf of a native root returns EBUSY.

Learn more

The seccomp filter is installed on computerd's spawning thread, so commands inherit it even if they change UID. The guard reads each intercepted pathname from the command's memory in read_target_string. Linux applies ptrace credential checks to process_vm_readv; a daemon with CAP_SYS_ADMIN but without CAP_SYS_PTRACE cannot read a differently credentialed process. The guard then resumes the syscall without detaching the bind mount, so removal reaches EBUSY.

Example: With /workspace/node_modules mounted, spawn su nobody -s /bin/sh -c 'rm -rf /workspace/node_modules' from the shell RPC on a host granting computerd CAP_SYS_ADMIN but not CAP_SYS_PTRACE. The child's inherited filter pauses the removal, but the guard cannot read its pathname, and the command fails with EBUSY.

Recommended fix: Handle pathname resolution for UID-changing children without relying on cross-UID memory access, or explicitly disable native roots or transparent removal when the required capability is unavailable. Add an integration case using a different UID and the production capability set.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

Comment on lines +803 to +813
const nativeRootsWanted = nativeRootsRequested(process.env);
let removal: { active: boolean; reason: string } | undefined;
const nativeRoots: NativeRoots | undefined =
ignoreConfig.enabled && nativeRootsWanted && backend.kind !== "none" && backend.kind !== "shim"
? createNativeRoots({
root: ignoreConfig.root,
mountPoint,
ignore: ignoreConfig.ignore,
active: () => fuse?.getPassthroughStatus?.().negotiated === true,
})
: undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃攳 Description omits default native roots

With MOUNT_IGNORE set, eligible directories are bind-mounted by default. Their metadata bypasses computerd, contrary to the PR description's diagram and performance comparison.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

Comment on lines +346 to +354
for (int i = 0; i < count; i++) {
char raw[PATH_MAX];
char absolute[PATH_MAX];
if (read_target_string((pid_t) req->pid, paths[i], raw, sizeof(raw)) != 0) continue;
if (resolve((pid_t) req->pid, dirs[i], raw, absolute, sizeof(absolute)) != 0) continue;
/* The task may have died, or the call been interrupted, since. */
if (ioctl(listener, SECCOMP_IOCTL_NOTIF_ID_VALID, &req->id) != 0) return;
detach_if_root(absolute);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃煡 Failed rename can detach native roots

When renameat2 targets a native root, the guard detaches it before the kernel checks the rename. An unprivileged command can detach a protected mount even when its rename fails.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

A local-only backing directory can survive a container snapshot while
the synced workspace parent becomes a symlink to a path outside the
workspace. On restore, the path-based native-root bind follows that
symlink and mounts the backing directory outside the FUSE tree. The
mount is not counted as active there and normal cleanup cannot find it.

Add a real-FUSE regression that plants the backing directory and
outside victim, restores the snapshot, and checks the outside victim
was neither covered nor changed. It fails against the current mount
code. This commit records the vulnerability; it is not a fix and must
not be deployed until the bind uses directory descriptors confined to
the workspace and backing roots.
A restored workspace symlink can redirect a path-based privileged bind
onto a directory outside /workspace. Resolve both mount endpoints under
pinned directories in a native worker and attach a private clone by
descriptor. If the helper is unavailable, leave the root on FUSE. Use
confined opens and detaches for guard registration and cleanup as well.

The focused unit and real-FUSE suites pass on arm64. Native x86_64 and
Cloudflare Container behavior, deterministic path-swap tests, and a
full-suite pass remain unverified. Keep native roots disabled in
production until those checks and a security review are complete.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 0 new potential issues.

Devin Review

The native-root bind no longer calls util-linux mount, and the 0.7.0
image no longer links libfuse 2. Update failure hints, example
packages, and debugging instructions to match. Clarify the mount
ownership assumption and distinguish earlier benchmarks from mount
setup time.
The removal guard is installed once and never unset. Capture that
instance before opening a mount descriptor, rather than rechecking a
state that cannot become undefined. State explicitly that mountinfo
ownership assumes no unrelated mounts below the workspace.
The real-FUSE info test compares the whole response. Native-root
status now includes removal guard availability and detach counts even
when MOUNT_IGNORE is unset, so include those fields in the expected
response rather than treating them as unexpected output.
A detach request could observe no mount while a bind was still in
flight, return an empty result, and leave the root mounted after the
caller tried to remove it. Park a requested root before waiting for
its bind, then detach the mount that actually landed. Suspend new
mount attempts while a detach-all operation waits for pending binds;
startup cleanup still leaves roots available for its subsequent scan.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

allow-pr Allow a PR to remain open.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant