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:
- Loads the QuickJS WASM runtime via
getQuickJS() - Creates a new
QuickJSContext - Installs timer globals with quota enforcement via
setupGlobals() - Injects the plugin API through
injectPluginApi() - 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
BridgeInitMessagecontaining the bundle source and manifest; the worker resolves itsinitPromise(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 fromregisteredHooks, builds actxobject, executes the handler viahandleHookEnter(lines 197-260), and postshookExit - Command Execution:
executeCommandevents route to local handlers or forward across the bridge viahandleExecuteCommand - Deactivate/Shutdown:
handleDeactivateruns lifecycle cleanup handlers before termination, whileshutdownforces 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 = 100concurrent timers (setTimeout/setInterval) - Delay ceiling: Maximum delay of
MAX_DELAY = 30seconds - 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 taskscommands.register(): Exposes local commands callable via the command palettelifecycle.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 payloadsstorage.set(),metadata.set(): Persist plugin data to Motrix's key-value storenotify.show(): Display UI toast notifications to the usercrypto.hash(),crypto.hmac(),crypto.randomBytes(),crypto.aes(): Cryptographic operations including hashing and encryptionconfig.get(): Read-only access to Motrix configuration values
File System Operations
The fs.storage namespace provides sandboxed file access:
read(path): ReturnsUint8Arraywrite(path, data): AcceptsUint8Arrayor stringsdelete(path),rename(oldPath, newPath): Mutation operationsexists(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 objectextractAudio({input, output}): Audio extraction
Each method returns a handle containing:
result: A Promise resolving when the operation completespollProgress(): Returns current progress percentageabort(): 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 sourcecall: Invokes effectful capabilities with{id, capability, method, args}event: Lifecycle events likehookEnter,executeCommand,localeChange
Worker to Host:
ready: VM boot successfulfatal: Unrecoverable error with stack traceresponse: 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.tssystem 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.tsmessages withjsValueToVmHandlemarshaling. - 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →