Skip to content

Sandbox image: /dev/fd is missing, so bash process substitution fails at runtime (eve 0.30.6) #2565

Description

@jfonturbel

Summary

Inside an eve sandbox, bash process substitution fails at runtime because the path /dev/fd
does not exist in the environment the sandbox provides. Any script using < <(…), >(…), a
coprocess, or wait $! on a substituted process breaks there — and the < <(…) read-loop form
breaks silently, which is how we found it.

What we observe

A script declaring #!/usr/bin/env bash and reading a command's output through process
substitution emits, at the line carrying the construct:

<script>: line <N>: /dev/fd/63: No such file or directory

This is not a "wrong shell" problem. A shell without process substitution would raise a syntax
error
at parse time. Here bash parsed the construct, forked the producer and allocated the pipe —
and then failed to open the path /dev/fd/63. On Linux /dev/fd is conventionally a symlink to
/proc/self/fd; in the sandbox environment it is absent, so the redirection has nothing to open.

Reproduction

We hit this in longer scripts; reduced to the smallest form that carries the construct:

#!/usr/bin/env bash
n=0
while IFS= read -r line; do n=$((n + 1)); done < <(printf 'a\nb\n')
echo "n=$n"

Expected n=2. What we measured in the sandbox is the redirection failing with the message above
and the loop body never running, so the count stays at its initial value. ls -l /dev/fd from the
same shell shows whether the path is present.

Why a consumer cannot work around this in configuration

We first looked for a way to select a different sandbox runtime from our own code, and eve
0.30.6 rules that out by construction. dist/src/public/sandbox/vercel-sandbox.d.ts declares:

type VercelSandboxAuthorCreateOptions<T> = T extends unknown ? Omit<T, "name" | "onResume" | "persistent" | "runtime" | "signal"> & VercelSandboxInternalCreateOptions : never;

and its docblock says why runtime is on that list:

runtime is excluded as well: eve always boots its sandboxes from the published eve image, which
is mutually exclusive with a stock runtime.

So there is no author-facing option that reaches this: the image is eve's, and the runtime option
that would let a consumer try a different one is not expressible at all. That is what makes it worth
reporting rather than configuring around.

Where the gap likely lives — an analysis, not an owner

We are deliberately not asserting where the fix belongs, because the evidence does not settle it.

The Dockerfile at tag eve@0.30.6 is 87 lines, FROM ubuntu:26.04, and contains no /dev/fd
handling: no /dev/fd, no /proc mount, no ln -s anywhere in it. But that is expected and is
not evidence of a Dockerfile defect — image layers conventionally never carry /dev/fd at
all. /dev/fd is materialised at runtime, by the /proc mount the container or microVM runtime
performs when it boots the image; a stock ubuntu:26.04 layer carries it no more than eve's image
does. So the missing piece is plausibly in the runtime that boots the image rather than in the image
recipe, and the maintainers are far better placed than we are to say which. We are reporting the
symptom and the analysis; please place the fix where it actually belongs.

One detail worth flagging either way: the image's own build uses bash —
SHELL ["/bin/bash", "-o", "pipefail", "-c"] — so bash is clearly intended to work in this
environment, and process substitution is a core bash feature rather than an exotic one.

Blast radius

Everything that rides the same /dev/fd path is affected:

  • < <(…) — feeding a command's output into a while read loop without a subshell;
  • >(…) — writing into a command, e.g. tee >(…);
  • coprocesses;
  • wait $! on a process substitution — including the idiom normally used to recover the producer's
    exit status.

The first form is the dangerous one: it fails open. The redirection error goes to stderr, the
loop body simply never runs, and the script continues with a zero exit status and an empty result.
In our case the affected loops were build/verification guards that enumerate changed files in order
to refuse
— reading zero files, they refused nothing and reported success. Nothing crashed; the
answer was just wrong in the permissive direction.

What we did meanwhile

We rewrote the affected loops to write the producer's output to a temp file and check the producer's
exit status explicitly, so an unreadable result becomes a loud refusal instead of an empty list.
That is a good change on its own merits (< <(…) leaves the producer's status unread on every host,
not just this one), so we are not asking for it back — but it is a workaround for the missing path,
not a fix, and it does not help anyone who reaches for >(…) or a coprocess in a sandbox.

Environment

  • eve 0.30.6
  • sandbox backend: Vercel Sandbox, via eve's published image
  • script interpreter: #!/usr/bin/env bash

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions