OapiCodemode.Executor.Deno (oapi_codemode v0.2.0)

Copy Markdown View Source

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).