Differences from the Python SDK
- Monty: each run executes in a crash-isolated worker.
os.chdir()raisesRuntimeError; usecdin the shell or the run’sctx.cwd.os.stat()has only the named fields (st.st_size). - QuickJS: a
stdandosshim with the calls the QuickJS table lists. A write shows to other commands once the file is closed. A timeout stops the run, and the engine caps a run at 64 MiB of memory and 1 MiB of stack. - Local: in
@struktoai/mirage-nodeonly. With neitherhomenorMIRAGE_LOCAL_HOME,python3is the one onPATH. - Sandboxes: in
@struktoai/mirage-node;e2balso runs in browsers.mountTypesshandbothneed Node and thessh2package. Thesshruntime authenticates throughSSH_AUTH_SOCKwhen there is noidentityFile, andusernamedefaults to the local user. - Config objects are camelCase in code (
sandboxId,mountType); YAML spells the same keys in snake_case. - Pyodide is in the TypeScript SDK only; WASI is in the Python SDK only.
Workspace.shell() result: stdout and
stderr are Uint8Array; use stdoutText and stderrText when you want
decoded strings.
Pyodide
Scripts and flags
- Each command and one-shot evaluation starts a fresh interpreter, so imports do not carry over; files on the mounts do. A console session keeps its own interpreter.
- A script runs as in CPython:
__file__is absolute, tracebacks quote its lines, andsys.path[0]is its directory.-Pleaves that entry off, and for-cand stdin omits the working directory entry.sys_pathentries stay either way. -P,-O/-OOand-Bshow in a read-onlysys.flags.-Oand-OOcompile imported modules from source. Flags and import paths reset when the command ends, errors included.- An installed Python CLI gets bare
argv(its name at index 0) andstdin(bytes, orNone), as on Monty; Pyodide also keepssys.argvandsys.stdin. - Standard streams support text and
.bufferI/O, but do not expose native OS file descriptors.
Files
- Native packages such as PIL and NumPy reach the same mounts as
open(). - A file is fetched on first access and cached for the run; edits made elsewhere during the run are not seen.
- A create, delete, rename, mkdir, chmod or truncate reaches the mount when the call is made, and a write when the file is closed, so a refusal fails that call with the mount’s errno. Writes still open at the end land then; a failure there exits nonzero with a message. Concurrent writes are not conflict-checked.
- A path no mount serves answers
ENOTSUPtoos.getxattr. - A console keeps its own
os.chdir(), even through an exception; if its directory disappears, the next input fails, resets the working directory to/and keeps the variables. - Mount files under a prefix such as
/data: a mount at/cannot replace Pyodide’s own filesystem, which holds its standard library.
Worker and browser setup
Pyodide runs in a worker, which Node needs no setup for. In a browser, the page needs one of two things:- Cross-origin isolation: the page is served with
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp. - Mirage’s service worker, on any other page: copy
node_modules/@struktoai/mirage-browser/dist/pyodide-sw.jsto the folder your site serves at its root, and register it before the firstpython3:
register covers a first visit and a hard
reload, which both leave the page uncontrolled. Service workers need HTTPS or
localhost. A site that already has a service worker adds the bridge to it
instead: call importScripts('/pyodide-sw.js') at the top of that worker
(import '/pyodide-sw.js' in a module worker) and register it in place of
/pyodide-sw.js.
With neither, python3 exits 127 and says why. JSPI is not needed.
In a browser, Pyodide loads from its CDN unless home names another place,
such as a folder on your site holding the pyodide package’s files
(home: '/pyodide/'). Mirage starts its workers from blob: URLs, so a
Content-Security-Policy needs blob: in worker-src, and
https://cdn.jsdelivr.net in script-src and connect-src while Pyodide
loads from the CDN.
Concurrency and timeouts
max_concurrency(default 1) bounds active top-level commands, one-shot evaluations and inputs to distinct consoles; extra calls wait. Calls in one shell session stay ordered; give independent work its own session withws.createSession(id)and{ sessionId: id }. Nested subprocesses bypass the limit. In a browser the limit stays 1, so commands from every session take turns; a secondWorkspaceruns alongside the first.- A timeout raises
KeyboardInterruptin the program, even in a busy loop. That needsSharedArrayBuffer. Without it, a workspace command’s worker is stopped instead, and a standalone run on the main thread blocks the event loop until it ends.
Sockets
connect and sendto raise ENETUNREACH, and listen, a UDP bind and
socket.socketpair() raise ENETDOWN, so urllib.request.urlopen fails
fast. Compare against errno.ENETUNREACH, not a number: the values are
Emscripten’s. socket.socket(), a TCP bind and name lookups still answer.
An init_module that calls useNodeSockFS() installs real sockets.
Containment
Guest Python can run JavaScript in its worker through Pyodide. On Node withmodule.registerHooks, Mirage hardens the worker after interpreter setup
and before guest execution:
- Further module loads are refused, including
import('node:child_process')and imports of already loaded Node modules. - Host globals (
process,global,fetch,WebSocket, and an injectedrequire) are removed. - Exposed host filesystem and socket APIs are disabled, including
mountNodeFS,mountNativeFS,useNodeSockFS,NODEFS, and host file preloading. Binary reads are restricted to configured local distribution and package directories, with traversal and symlink escape checks.
packages and trusted bootstrap_code run during initialization,
before the seal.
An init_module opts into host access (reach: process), so its worker is
left unsealed. Node releases without module.registerHooks, including Node
20 and Node 22 before 22.15, also run unsealed; use them only for trusted
code. Standalone SDK execution without a workspace can run on the main
thread and is trusted-only. Workspace-backed execution requires a worker.
Browser workers are left unsealed: JavaScript can still use fetch and
module loading, subject to the page’s Content Security Policy. Each command
gets its own worker.
These measures close known host access paths. They do not make arbitrary
JavaScript safe: Pyodide exposes mutable interpreter internals, and a worker
thread shares the host process. For hostile code, use a
sandbox with a process and kernel boundary.
Child processes
subprocess runs Mirage commands:
- Supported:
run,call,check_call,check_output,Popenwith live pipes,poll,wait,communicate, termination, timeout retries, text mode,stderr=STDOUT, andshell=Truethrough the workspace’ssh. sys.executableis/usr/bin/python3, andshutil.whichsees the profile’s programs. Functions and shell builtins are not programs.- The environment is inherited unless
envreplaces it. Writes land before each process call, and file caches reset after it. - A timed-out
waitorcommunicateleaves the child running;runkills it. Children still running when the program ends are cancelled. - Not supported: native descriptors, PTYs, process groups, user switching,
preexec_fn,asyncio.create_subprocess_execand signal handlers. An unsupported option fails with an error.
Configuration
The package keys are on the Pyodide page. The others:init_module runs per command or evaluation and once per console, inside
the worker. It has host privileges, so keep it in deployment config:
POST /v1/workspaces rejects it, and setting it gives the runtime host
access. Only an in-process shutdown calls its cleanup.
E2B in browsers
Import the runtime from core, which also registerse2b for YAML, and pass
apiKey:
mountType ssh and both need Node, so a browser
uses fuse or none.
Managing runtimes in code
new Workspace(mounts, { runtimes: [...] }) takes names and runtime objects,
and adds workspace when the list leaves it out. On a live workspace,
addRuntime appends an entry (the first capturer still wins),
removeRuntime takes one out, and runtimes() lists them:
python3 moves to the new runtime at once. A running line finishes before
its removed runtime closes, a removed instance cannot be added again, and
workspace cannot be removed. registerRuntime(name, cls) makes your own
runtime class available by name in YAML. The @external capture is also
exported as EXTERNAL_COMMANDS.
A sandbox subclass that overrides loadSdk() imports the SDK type from the
provider’s sdk module: