QUESTPIE
Infrastructure

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.

View markdown

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

AdapterImport fromServesWhat it needs
InProcessExecutorAdapterquestpie/executorisolation: "trusted"Nothing, it is already the default
httpSandboxAdapter()@questpie/sandbox/adapterisolation: "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

src/questpie/server/questpie.config.ts
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.

KeyGrantsEnforced by
netfetch() hosts, host[:port]Deno --allow-net, or the broker's pinned fetch with bindings
importRemote module hostsDeno --allow-import, see the callout
timeoutMsWall clock, default 5000, max 30000The supervisor clamps it
memoryMbV8 old space, default 128, min 16, max 1024The supervisor clamps it
files, data.collections, data.storesBrokered access to your appThe broker, on every call
services, jobs, workflowsDeclared and capability-checkedNothing 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.

On this page