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
Summary
Inside an eve sandbox, bash process substitution fails at runtime because the path
/dev/fddoes not exist in the environment the sandbox provides. Any script using
< <(…),>(…), acoprocess, or
wait $!on a substituted process breaks there — and the< <(…)read-loop formbreaks silently, which is how we found it.
What we observe
A script declaring
#!/usr/bin/env bashand reading a command's output through processsubstitution emits, at the line carrying the construct:
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/fdis 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:
Expected
n=2. What we measured in the sandbox is the redirection failing with the message aboveand the loop body never running, so the count stays at its initial value.
ls -l /dev/fdfrom thesame 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.tsdeclares:and its docblock says why
runtimeis on that list:So there is no author-facing option that reaches this: the image is eve's, and the
runtimeoptionthat 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
Dockerfileat tageve@0.30.6is 87 lines,FROM ubuntu:26.04, and contains no/dev/fdhandling: no
/dev/fd, no/procmount, noln -sanywhere in it. But that is expected and isnot evidence of a Dockerfile defect — image layers conventionally never carry
/dev/fdatall.
/dev/fdis materialised at runtime, by the/procmount the container or microVM runtimeperforms when it boots the image; a stock
ubuntu:26.04layer carries it no more than eve's imagedoes. 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 thisenvironment, and process substitution is a core bash feature rather than an exotic one.
Blast radius
Everything that rides the same
/dev/fdpath is affected:< <(…)— feeding a command's output into awhile readloop without a subshell;>(…)— writing into a command, e.g.tee >(…);wait $!on a process substitution — including the idiom normally used to recover the producer'sexit 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
0.30.6#!/usr/bin/env bash