How Motrix's QuickJS Plugin Sandbox Works: Architecture, Security, and APIs

Motrix isolates plugins inside a QuickJS WebAssembly VM running in a Node.js worker thread, exposing a sealed __motrix_plugin_api__ object with registration-only and effectful capabilities that are gated by lifecycle phases and enforced by a bridge protocol.

Motrix, the open-source download manager, implements a secure plugin system using QuickJS to execute untrusted code safely. The sandbox runs each plugin in an isolated virtual machine without access to Node.js APIs, exposing functionality through a carefully controlled bridge. This architecture ensures that third-party extensions can extend Motrix's capabilities without compromising system security.

Architecture Overview

Worker Thread Isolation

Each Motrix plugin executes inside a dedicated Node.js Worker thread defined in src/core/plugin/host/quick-js-worker.ts. The worker loads the QuickJS WebAssembly runtime (quickjs-emscripten) and creates a fresh VM context for every plugin instance. This isolation prevents plugins from accessing the main process memory or Node.js native modules directly.

The worker entry point spawns at lines 27-34, then waits for an initialization message containing the plugin bundle, manifest metadata, and locale settings before booting the VM.

VM Initialization and Boot Sequence

The boot() function (lines 104-125) orchestrates VM startup:

  1. Loads the QuickJS WASM runtime via getQuickJS()
  2. Creates a new QuickJSContext
  3. Installs timer globals with quota enforcement via setupGlobals()
  4. Injects the plugin API through injectPluginApi()
  5. Evaluates the transformed plugin bundle using vm.evalCode()

Upon successful evaluation, the worker posts {type:'ready'} to the host and enters the idle phase, signaling that the plugin is ready to receive hooks and commands.

Plugin Lifecycle Phases

The Motrix QuickJS plugin sandbox enforces a strict lifecycle that controls when specific operations are permitted.

  • Spawn: The main process creates the worker thread running quick-js-worker.ts
  • Init: The host sends a BridgeInitMessage containing the bundle source and manifest; the worker resolves its initPromise (lines 56-66)
  • Boot: The VM initializes, installs globals, and evaluates the plugin code
  • Ready: The worker confirms successful activation and transitions to idle
  • Hook Execution: When the orchestrator fires an event, the host sends hookEnter, the worker retrieves the registered handler from registeredHooks, builds a ctx object, executes the handler via handleHookEnter (lines 197-260), and posts hookExit
  • Command Execution: executeCommand events route to local handlers or forward across the bridge via handleExecuteCommand
  • Deactivate/Shutdown: handleDeactivate runs lifecycle cleanup handlers before termination, while shutdown forces immediate process exit

Security Model and Capability Classification

Phase-Gated Access Control

Motrix classifies all plugin APIs as either registration-only or effectful using definitions in src/core/plugin/capabilities/classification.ts. During the activation phase, only registration-only methods (like hooks.register or commands.register) are permitted. Any attempt to invoke an effectful method triggers assertEffectfulAllowed (lines 82-95), which raises a fatal error with code plugin.lifecycle.activation_capability_violation and terminates the plugin.

Effectful methods—those that perform I/O, network requests, or state changes—use the makeEffectfulNs() wrapper. This utility marshals arguments via jsValueToVmHandle, sends a call message across the bridge, and returns a QuickJS Promise that resolves with the host's response.

Resource Limits and Quotas

The sandbox enforces strict resource constraints through setupGlobals():

  • Timer limits: A maximum of MAX_ACTIVE = 100 concurrent timers (setTimeout/setInterval)
  • Delay ceiling: Maximum delay of MAX_DELAY = 30 seconds
  • Fire-and-forget logging: log.info(), log.debug(), and related methods bypass phase gates and run immediately without bridge round-trips

Sandboxed File System

All filesystem operations route through fs.storage and fs.task capabilities injected into the VM. These methods enforce the plugin-root sandbox implemented in src/core/plugin/capabilities/fs-sandbox.ts, preventing directory traversal outside the plugin's dedicated storage directory. Read, write, delete, and rename operations all require bridge confirmation and path validation before execution.

Available Plugin APIs

Core API Structure

The injectPluginApi() function (lines 85-115) constructs a sealed globalThis.__motrix_plugin_api__ object containing namespaces for all capabilities. Effectful namespaces use makeEffectfulNs() to wrap bridge calls, while read-only namespaces (like app and i18n) return snapshots without bridge overhead.

Registration-Only APIs

These methods are safe to call during activation and simply store callbacks or metadata:

  • hooks.beforeCreate(), hooks.afterComplete(): Register lifecycle interceptors for download tasks
  • commands.register(): Exposes local commands callable via the command palette
  • lifecycle.onDeactivate(): Registers cleanup handlers that run before plugin shutdown

Effectful APIs

Available only after successful activation, these methods perform privileged operations:

  • http.request(), http.get(), http.post(): Make network requests with configurable headers and payloads
  • storage.set(), metadata.set(): Persist plugin data to Motrix's key-value store
  • notify.show(): Display UI toast notifications to the user
  • crypto.hash(), crypto.hmac(), crypto.randomBytes(), crypto.aes(): Cryptographic operations including hashing and encryption
  • config.get(): Read-only access to Motrix configuration values

File System Operations

The fs.storage namespace provides sandboxed file access:

  • read(path): Returns Uint8Array
  • write(path, data): Accepts Uint8Array or strings
  • delete(path), rename(oldPath, newPath): Mutation operations
  • exists(path), stat(path): Metadata queries

All paths are resolved relative to the plugin's root directory and validated against escape attempts.

FFmpeg Integration

The ffmpeg namespace exposes video processing capabilities:

  • transcode({input, output, videoCodec}): Returns a handle object
  • extractAudio({input, output}): Audio extraction

Each method returns a handle containing:

  • result: A Promise resolving when the operation completes
  • pollProgress(): Returns current progress percentage
  • abort(): Terminates the FFmpeg process

Bridge Protocol and Data Marshaling

Host-Worker Communication

The bridge protocol defined in src/core/plugin/host/bridge-protocol.ts standardizes all cross-boundary communication using typed messages:

Host to Worker:

  • init: Carries locale data and bundle source
  • call: Invokes effectful capabilities with {id, capability, method, args}
  • event: Lifecycle events like hookEnter, executeCommand, localeChange

Worker to Host:

  • ready: VM boot successful
  • fatal: Unrecoverable error with stack trace
  • response: Result of effectful call with {id, ok, result|error}
  • event: Plugin-initiated events

Data Serialization

Argument marshaling uses jsValueToVmHandle() (lines 75-95) to convert JavaScript primitives, arrays, and plain objects into QuickJS handles. Typed arrays are serialized as numeric arrays. Return values are extracted via vm.dump(handle) and transmitted back to the host. Errors include standardized codes via vmErrorCode and messages via vmErrorMessage for programmatic error handling.

Implementation Examples

Basic Plugin with Logging and Hooks

// plugin.ts – bundled and transformed by Motrix's build step
export default async function (motrix) {
  const { log, hooks } = motrix

  log.info('Plugin loaded!')

  hooks.beforeCreate((ctx) => {
    log.debug('Before create hook', { taskId: ctx.taskId })
  })
}

Making HTTP Requests

export default async function (motrix) {
  const { http, log } = motrix

  // Effectful call – allowed only after activation
  const resp = await http.request({
    url: 'https://api.github.com/repos/agalwood/Motrix',
    method: 'GET',
  })

  const data = await resp.json()
  log.info('Repo data received', { stars: data.stargazers_count })
}

Sandboxed File Storage

export default async function (motrix) {
  const { fs, log } = motrix

  await fs.storage.write('my.txt', new Uint8Array([72, 105])) // "Hi"
  const buf = await fs.storage.read('my.txt')
  log.info('Read back', { text: new TextDecoder().decode(buf) })
}

FFmpeg Transcoding

export default async function (motrix) {
  const { ffmpeg, log } = motrix

  const handle = await ffmpeg.transcode({
    input: '/path/in.mp4',
    output: '/path/out.mp4',
    videoCodec: 'libx264',
  })

  const result = await handle.result   // resolves when transcode finishes
  log.info('Transcode finished', { result })
}

Summary

  • Isolated Execution: Motrix runs plugins in QuickJS VMs inside Node.js worker threads, preventing access to native APIs.
  • Phase-Gated Security: The classification.ts system enforces registration-only calls during activation and effectful calls afterward, aborting violations immediately.
  • Comprehensive API: Plugins access HTTP, sandboxed FS, FFmpeg, crypto, and UI notifications through globalThis.__motrix_plugin_api__.
  • Bridge Architecture: All privileged operations serialize through bridge-protocol.ts messages with jsValueToVmHandle marshaling.
  • Resource Limits: Built-in quotas restrict active timers (max 100) and delays (max 30s) to prevent resource exhaustion.

Frequently Asked Questions

What JavaScript engine powers Motrix's plugin sandbox?

Motrix uses QuickJS compiled to WebAssembly via the quickjs-emscripten package. The engine runs inside a Node.js worker thread defined in src/core/plugin/host/quick-js-worker.ts, creating a fresh VM context for each plugin without access to Node.js native modules or the filesystem.

How does Motrix prevent plugins from accessing the filesystem outside their directory?

All filesystem operations route through fs.storage methods that enforce the plugin-root sandbox implemented in src/core/plugin/capabilities/fs-sandbox.ts. The host resolves all paths relative to the plugin's dedicated storage directory and rejects path traversal attempts (like ../) before executing any read or write operations.

What happens if a plugin tries to make an HTTP request during the activation phase?

The sandbox calls assertEffectfulAllowed (lines 82-95) before executing any effectful operation. If the plugin is still in the activation phase, this check fails and triggers a fatal error with code plugin.lifecycle.activation_capability_violation. The worker immediately terminates the plugin process and reports the violation to the host.

Can plugins execute system commands or access native binaries like FFmpeg?

Plugins cannot execute arbitrary system commands, but they can access sandboxed FFmpeg functionality through the ffmpeg API namespace. Methods like ffmpeg.transcode() return a handle that communicates with the host's FFmpeg binary through the bridge protocol, allowing video processing without exposing shell access or binary paths to the plugin code.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →