Sandbox
Dynamic code runs through one call with two isolation modes. Trusted stays in your process. Sandboxed goes to a fresh Deno subprocess with a memory cap, an empty environment and an explicit host allowlist, and refuses to run until you configure an adapter.
The call is always ctx.executor.run(). What sits behind it is decided by one
field on the run, isolation, and by which adapter you put in the matching
config slot. This page carries both, so the swap is visible in one place.
With no configuration
Leave executor out of your runtime config and the service is built disabled.
Every run() throws, naming the config key it wants. There is no fallback,
because an executor that quietly ran untrusted source in your process would hand
it your database credentials.
Two defaults decide what happens once you do configure it.
isolation defaults to "sandboxed"
A run that names no mode is treated as untrusted. Trusted callers opt in with
isolation: "trusted" on purpose.
Only the trusted mode has a built-in
executor.trusted defaults to core's InProcessExecutorAdapter. Nothing
defaults into executor.sandboxed, so a sandboxed run throws until you install
an adapter there. Both of those are throws, not { ok: false } results.
The two adapters
| Adapter | Import from | Serves | What it needs |
|---|---|---|---|
InProcessExecutorAdapter | questpie/executor | isolation: "trusted" | Nothing, it is already the default |
httpSandboxAdapter() | @questpie/sandbox/adapter | isolation: "sandboxed" | A Deno supervisor sidecar and a shared secret of 32+ bytes |
Both satisfy the same one-method contract, so choosing between them is a config line and nothing at the call site moves.
What trusted actually does
It base64s your source into a data: URL and import()s it in the host
process. On the way it captures console.* into the result's logs, exposes
secrets as globalThis.__secrets, sets any bindings you passed on
globalThis, and races a soft timeout. That timeout is the run's own
capabilities.timeoutMs when it has one, otherwise executor.defaultTimeoutMs,
otherwise 30000ms. Those are shared process globals, so trusted runs are
serialized by a module-level mutex and one wedged guest queues the next.
Trusted is not a weak sandbox, it is no sandbox
The guest gets everything the host process has. The soft timeout bounds your wait, not the guest, so a tight synchronous loop is never preempted. Use this mode only for source you wrote and reviewed.
What sandboxed actually does
httpSandboxAdapter POSTs the run to the Deno supervisor, which spawns one
fresh subprocess per run with --v8-flags=--max-old-space-size=<memoryMb>, an
empty environment, cwd /, and no filesystem grant at all, since the guest is
loaded as a self-contained data: module. Before your source is imported,
Worker is nulled and SharedArrayBuffer and Atomics are deleted.
Configure the sandbox
import { httpSandboxAdapter } from "@questpie/sandbox/adapter";
import { runtimeConfig } from "questpie/app";
export default runtimeConfig({
app: { url: process.env.APP_URL! },
db: { url: process.env.DATABASE_URL! },
executor: {
sandboxed: httpSandboxAdapter({
url: process.env.SANDBOX_URL,
hostAdmissionSecret: process.env.SANDBOX_HOST_ADMISSION_SECRET,
}),
// Only when guests reach your app. See Brokered app access.
brokerUrl: process.env.SANDBOX_BROKER_URL,
},
});httpSandboxAdapter(options?) takes url (falls back to SANDBOX_URL, and must
be a bare origin with no path, query, userinfo or fragment), hostAdmissionSecret
(falls back to SANDBOX_HOST_ADMISSION_SECRET), fetchTimeoutMs (default: the
guest timeout plus 10s), validateEgress (default true) and a custom fetch.
The adapter is only half of it. The supervisor is a separate Deno process that
ships as source under node_modules, so your app image stays Deno-free, and it
needs the same SANDBOX_HOST_ADMISSION_SECRET or it refuses your runs.
Running the supervisor has the command
and its environment.
Swapping the adapter
Both slots take any object with a run method, so pointing sandboxed runs at a
different isolation service, or trusted runs somewhere other than in-process, is
a one-line change in the same config. Call sites never learn which one answered.
Run guest code
Guest source must export default a function(input), and everything crossing
the boundary is serializable. Grant only the hosts it needs.
const result = await ctx.executor.run({
source: `export default async function (input) {
const res = await fetch("https://api.example.com/data?since=" + input.since);
return { count: (await res.json()).length };
}`,
input: { since: "2026-01-01" },
capabilities: { net: ["api.example.com"], import: [], timeoutMs: 5_000 },
});
// → { ok: true, output: { count: 42 }, logs: [...], ms: 312 }The result is { ok, output?, logs, error?, timedOut?, ms? }. A supervisor that
is down and a guest that throws both come back as ok: false. Only
misconfiguration throws.
The capability manifest
Every run declares what it may reach, and omitted means denied. Treat building the manifest as an authorization decision rather than passing request data through.
| Key | Grants | Enforced by |
|---|---|---|
net | fetch() hosts, host[:port] | Deno --allow-net, or the broker's pinned fetch with bindings |
import | Remote module hosts | Deno --allow-import, see the callout |
timeoutMs | Wall clock, default 5000, max 30000 | The supervisor clamps it |
memoryMb | V8 old space, default 128, min 16, max 1024 | The supervisor clamps it |
files, data.collections, data.stores | Brokered access to your app | The broker, on every call |
services, jobs, workflows | Declared and capability-checked | Nothing yet, the broker answers not_implemented |
An empty `import` list is not the same as silence
Omitting Deno's --allow-import does not deny. It grants seven default hosts
including esm.sh and jsr.io. So an empty allowlist emits an explicit
--deny-import of exactly those. Never alias import to net, they are
independent axes.
Egress
Both net and import hosts are validated before any socket opens, in the
adapter and again in the supervisor. A host that is or resolves to a private,
loopback, link-local or CGNAT address, or to 169.254.169.254, is rejected, and
a DNS failure is a rejection too. On capable Linux each run additionally gets its
own network namespace with a default-drop nftables ruleset. Everywhere else that
layer logs a notice and is absent, so treat the subprocess flags as the real
boundary.
Give the guest your app
Pass appBindings and a brokerUrl and the guest gets globalThis.questpie,
a proxy whose calls are relayed server to server. The executor mints a per-run
token that never enters the guest process, and the broker rechecks the manifest
on every call.
// inside the guest
const posts = await questpie.collections.posts.find({ limit: 10 });Brokered app access covers the guest surface, the route you have to register, and what the broker refuses.
Writing your own adapter
ExecutorAdapter is one method. Import it from questpie/executor.
import type { ExecutorAdapter } from "questpie/executor";
export const myAdapter: ExecutorAdapter = {
async run({ source, input, capabilities, secrets, sandboxBindings }) {
// hand these to your runner, return { ok, output, logs, error?, ms? }
return { ok: true, output: undefined, logs: [] };
},
};ExecutorService picks the adapter by isolation before it calls you. On the
bindings path it has already minted the token and put it on
options.sandboxBindings as { url, token }. Forward that to your runner and
never to the guest.
Next
Services is where ctx.executor sits among the other
injected objects, and MCP is the other place you hand
declared capabilities to code you did not write.