Subprocess Deno executor. Resurrects ele's Exile-bridge design
(ele-core a2a52478f) over a raw Port with three properties the original
lacked: no temp files (data-URL import), concurrent callback dispatch,
and child reaping on every path this module can reach — we record the
OS pid at spawn and kill exactly that pid (never a pattern) both from
the worker's own after clause and, if the worker itself dies or
overruns, from run/3's backstop, which is why the worker reports the
pid to run/3 the moment the port is open.
Sandboxing: deno runs with --no-prompt and NO permission flags, so the
child has no fetch/TCP network access, no filesystem access, no env
access, and cannot spawn subprocesses. On its own that is not
"no network": Deno 2's default import allowlist lets import() of
https:/npm:/jsr: specifiers reach the network to fetch the module
even without --allow-net (the module loader is a separate permission
domain from fetch). We additionally pass --no-remote and --no-npm
to close that door — they disable remote (http/https/jsr) and npm module
resolution outright, so no permission grant could re-open it later
either. The data:text/typescript bootstrap import that loads the
sandboxed code itself is unaffected (data: is a local scheme, not
remote/npm). Callbacks are the sandbox's only door out.
Residual risk (protocol injection): bootstrap.ts patches console.log
and, once the sandboxed code starts running, attempts to lock down
Deno.stdout.write/writeSync so sandboxed code cannot forge protocol
lines on stdout. If a future Deno version makes those properties
non-configurable, that lockdown silently no-ops (bootstrap.ts documents
this at the call site) and a malicious script could in principle inject a
fake done line. Severity is low: the sandbox already runs with zero
permissions (no network/filesystem/env), so the worst case is a confused
result for that one run, not an escape from the sandbox.
Port ownership: Port.open/2 ties the port's messages — including the
{:exit_status, _} message the OS delivers when the child dies — to
whichever process calls it. run/3 may already have returned via the
"done" callback protocol before that trailing exit-status message
reaches the mailbox (the child writes "done" to stdout and calls
Deno.exit(0) back-to-back; the pipe-data and process-exit
notifications race independently). A caller that invokes run/3
synchronously from inside a long-lived process (a GenServer handling a
tool call, say) would otherwise see that straggler delivered to its next
unrelated receive/handle_info and crash on a message it has no
clause for — this happened for real integrating with a host's loop
server. So the whole port lifecycle runs inside a throwaway worker
process instead: any message that outlives the run dies with that
worker's mailbox rather than leaking into the caller.
The worker is unlinked (spawn_monitor, not Task.async). A linked
task sends a trapexit caller — the normal setup for a host GenServer —
an {:EXIT, pid, :normal} when it finishes, which is the same
unmatched-straggler crash in different clothing. run/3 consumes the
:DOWN before returning and flushes the monitor on every path, so it
hands the caller nothing that outlives the call and, because the worker
is unlinked and do_run/3 is total, never exits or raises into it
either: the return is always `{:ok, } | {:error, _}`.
Timeout is a wall-clock deadline, not an idle timer. It is computed
once at spawn and each receive in loop/5 waits only the remaining
time, so total sandbox wall time is bounded by :timeout no matter how
much callback traffic the sandbox generates (an infinite loop calling a
callback used to reset a per-receive timer forever and run unbounded).