OapiCodemode.Executor behaviour (oapi_codemode v0.5.0)

Copy Markdown View Source

The sandbox contract — the entire interface a TS execution environment must satisfy. Deliberately minimal: run code with globals and callbacks, return the value and console output.

Requirements for real implementations:

  • No network access inside the sandbox.
  • globals are injected as JSON data before the code runs.
  • every callback in env.callbacks is surfaced to the guest under the host object (see below).
  • callbacks may be invoked CONCURRENTLY (a guest's Promise.all under an engine that dispatches in parallel, as Executor.Deno does) — the callbacks the tool layer supplies are safe for that. Dispatching serially instead is allowed and is what the in-process engines do.
  • The boundary is JSON-native: values crossing it survive JSON encode/decode unchanged.
  • On timeout, return {:error, {:timeout, ms}}, and cancel any outstanding callback work first — killing the sandbox process is not enough on its own, since callbacks dispatched into Elixir Tasks can still be in flight and will try to write to a call log the tool layer has already torn down.
  • On a run-time error inside the sandbox, prefer {:error, %{message: String.t(), logs: [String.t()]}} when any console output was captured before the crash — the tool layer surfaces those logs to the caller even though the run failed. A bare {:error, term()} (no logs) is also accepted for errors that necessarily precede any code running (e.g. a raise in the executor itself).

Named host callbacks: the host object

env.callbacks is a map of name to an arity-1 function over the JSON-decoded argument listfn [api_name, opts] -> ... end, not fn api_name, opts -> ... end. An executor MUST expose each one to the guest as host.<name>(...args), i.e. build one host object per run whose properties are exactly the keys of env.callbacks, each forwarding its arguments as a list to its callback and returning what the callback returns (a JSON-native term). Nothing else may appear on host, and a name the run did not supply must not resolve.

Two names exist today, both supplied by OapiCodemode.Tools:

  • :request["<api name>", opts], execute runs.
  • :describe["<api name>", id | [ids]], search runs.

Keeping the contract at named callbacks over a list is what lets a new host-backed function be added without every executor learning its arity: an engine dispatches by name and forwards the list.

Ergonomic surfaces the model is actually taught are built on top of host: apis.<name>.request(opts) by the executor (below), and specs.<name>.describe(id | [ids]) by OapiCodemode.Tools, which wraps the guest's search function in a preamble of its own — it holds the index those bindings hang off, so the executor knows nothing about them.

The apiNames global (magic key)

env.globals always carries an "apiNames" entry — the list of registered API names for this run (set by OapiCodemode.Tools in do_execute/6). This is not an ordinary data global: a real executor MUST read globals["apiNames"] and, before running the sandboxed code, build one apis.<name>.request(opts) binding per name, each forwarding to the :request callback as [name, opts]. How the bindings are built is the engine's business: OapiCodemode.Executor.SafeJS emits an apis object literal in a JS preamble evaluated ahead of the guest code, and priv/deno/bootstrap.ts's installGlobals/3 builds the same object over the port protocol's rpc("request", [name, opts]). Either way the guest sees one shape. Everything else in globals is injected as inert data; apiNames alone is a build instruction for the sandbox's apis object. A third-party executor that treats it as just another data global will silently omit apis entirely and every execute_api_code call will fail with "apis is not defined" — there is no other signal pointing at this requirement, so implementers must know to look for it here.

Summary

Types

A host callback: the JSON-decoded argument list in, a JSON-native term out.

Types

callback()

@type callback() :: ([term()] -> term())

A host callback: the JSON-decoded argument list in, a JSON-native term out.

env()

@type env() :: %{globals: map(), callbacks: %{optional(atom()) => callback()}}

result()

@type result() :: %{value: term(), logs: [String.t()]}

run_error()

@type run_error() ::
  {:timeout, pos_integer()}
  | %{message: String.t(), logs: [String.t()]}
  | term()

Callbacks

run(code, env, opts)

@callback run(code :: String.t(), env(), opts :: keyword()) ::
  {:ok, result()} | {:error, run_error()}