WorkerDeck

Reference

Engines and executors

Which engine to run a session on, and where its sandboxed code should execute.

Two independent choices, often confused. The engine decides what kind of agent this is; the executor decides where its sandboxed tool calls run. Only the provider engine has the second choice at all.

Which engine?

claude codex provider
Runs the Claude Code CLI, via the Agent SDK the @openai/codex binary any AI SDK model, in-process
Host filesystem yes - a real cwd yes - a real cwd none; an in-memory scratch VFS
Interactive approvals yes yes (escalation after a sandbox refusal) no
Survives a restart dormancy: rebuilt lazily on first attach dormancy parking: full state snapshot
Credentials the operator’s claude login / API key the operator’s codex login your provider key, resolved by your hook
Extra process a CLI subprocess per session one child per session none
Best for working on a real repo, close to real Claude Code the same, on OpenAI’s stack an agent inside your product

The dividing line is the filesystem. claude and codex are for an agent that works on a checkout, with the operator’s own credentials and the operator’s own tooling - that is what makes them “close to real”. The provider engine has no shell and no host paths at all: its authority is exactly the tools you grant it, which is what makes it the one you can put in front of end users.

Two consequences of EngineCapabilities.hostCwd: false on the provider engine that surprise people: cwd is optional (a required field nothing reads is a lie), and allowedCwdRoots is therefore not the sandbox boundary for it. The capability wiring is.

Whatever you render, read the capability record rather than the engine name - TranscriptState.capabilities is always populated, and it is what makes one component correct for all three.

Codex only: project trust gates .codex/config.toml

If a repo carries its own .codex/config.toml - MCP servers, a model pin - codex reads it only when that project is trusted, meaning $CODEX_HOME/config.toml (usually ~/.codex/config.toml) holds an entry like:

[projects."/abs/path/to/your/repo"]
trust_level = "trusted"

The interactive codex CLI asks you the first time you run it somewhere (“do you trust this folder?”) and writes that entry for you. The app-server surface WorkerDeck drives cannot ask.

What happens next depends on the permission mode, because the gate is tied to the sandbox:

Mode Codex sandbox Untrusted project
Manual (default) read-only .codex/config.toml is ignored; WorkerDeck says so in the transcript
Accept edits, Auto workspace-write codex writes the trust entry itself, then loads the config
Bypass danger-full-access same - trusted on start

So the silent case is exactly the safest mode. In Manual, WorkerDeck detects it and posts a notice rather than leaving you to wonder where your MCP server went; the fix is to run codex once in that directory and accept the prompt, or add the entry by hand. WorkerDeck never writes it for you - the same reason it never touches your codex credentials.

Trust resolves per directory layer, from the cwd up to and including the nearest one containing .git. In the ordinary case - a .codex/config.toml at your repo root, trust on that root - a session started in any subdirectory is covered. Note that switching a running session to a wider mode does not retroactively load the config; start a new session instead.

Network access

Codex’s sandbox has a third axis that neither the permission mode nor the approval policy controls: in workspace-write, outbound network is off by default. A command that needs it does not raise an approval request - it just fails, typically with Could not resolve host, because a DNS failure is not a sandbox denial.

WorkerDeck does not decide this for you. Turn it on the way codex documents, in your ~/.codex/config.toml (or a project .codex/config.toml, subject to the trust rules above):

[sandbox_workspace_write]
network_access = true

WorkerDeck reads your effective setting for the session’s directory and restates it on every turn, so a session honours exactly what you configured - including writable_roots. Manual mode is unaffected: the setting is scoped to workspace-write, and a read-only sandbox has no network either way.

Which executor?

Only for the provider engine, and only for tools typed sandboxed (eval_script, plus any host tool you declare that way). Authoritative tools - MCP, and anything holding your credentials - always run in the gateway and may never be routed elsewhere.

QuickJsExecutor BrowserBridgeExecutor DeferredExecutor
Runs in this Node process, WASM guest the attached browser tab wherever you dispatch it
Needs a client attached no yes no
Latency in-process a WebSocket round trip unbounded - the session parks
Data locality data must reach the server client-held data never leaves the tab n/a
Result trust untrusted (it is a sandbox) untrusted, and produced by the sandboxed party depends on the backend

Ask where the data the loop reasons over already lives:

  • In your database or on your disk → in-process. Pushing execution into the tab buys nothing and hands an executor to the party you are sandboxing against.
  • In the user’s browser - a document they are editing, a file they dropped, something you would rather not receive at all → the bridge. That is the case it exists for.
  • Somewhere that answers in minutes or hours - a queue, a build, a human → deferred, and let the session park.

Two constraints decide it regardless of preference:

  • An unattended job has no attached client, so the bridge is unavailable to it. A profile used by both interactive sessions and queued jobs needs an in-process executor as at least a fallback.
  • A bridged result is produced by the sandboxed party. Never route anything authoritative there, and treat everything that comes back as input, not as fact.

The choice is per call, not per session - a routing executor can keep eval_script in-process and defer a long-running tool, and only the deferred one parks the session.

Setting up the in-process guest

The WASM variant is a peer dependency you install, because the browser build and the server build are different artifacts and only the host knows which side it is on. Load it once per process and share it across sessions:

import variant from '@jitl/quickjs-ng-wasmfile-release-asyncify'
import { loadEngine } from '@workerdeck/sandbox'
import { QuickJsExecutor } from '@workerdeck/core'

const executor = new QuickJsExecutor({
  engine: await loadEngine(variant),
  defaultTimeoutMs: 5_000,
  // No `allowedHosts` → the guest has no network at all. `web_fetch` is a
  // separate host-side tool with its own SSRF guard, and a script cannot reach it.
})

In a browser host the equivalent is @jitl/quickjs-singlefile-browser-release-asyncify, which useToolCallHost loads lazily by default - a page that never bridges a call never pays for it.