> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mirage.strukto.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Limitations

> Runtime-specific constraints in the TypeScript SDK (Node and Browser) and how to work around them.

Most of Mirage's behavior is identical across Python and TypeScript. The two TS runtimes (Node and Browser) each impose a handful of constraints, most of which don't exist in Python (the one shared design property, size-unknown stat, is included because it is the most common surprise). All are documented here so you can plan around them before they surprise you in production.

<Note>
  `python3` has its own set of WASM-runtime-level divergences from Python Mirage's subprocess model. See [Python](/typescript/python#limitations) for the full list.
</Note>

## Shell traps (Python, Node and Browser)

`trap 'commands' EXIT` (or `0`) registers cleanup for the shell that ends. It runs at `exit`, in the frame that called it, so a function's locals and `$1` are still in scope. It also runs when a child shell ends (`bash -c`, a script, `( ... )`, a pipeline stage, `$( )`, a background job) or when `set -e` or a fatal expansion error ends the shell. `$?` starts at the ending status. The status stays that unless the cleanup calls `exit N` or fails under `set -e`.

`trap -p` lists the registration, `trap - EXIT` removes it and `trap '' EXIT` turns cleanup off. A subshell lists its parent's registration but runs only one it registers itself. `bash` and `sh` start with none.

A workspace session persists across `shell()` calls, so a call that returns does not fire `EXIT`. Register the cleanup inside `bash -c` when it has to finish before the call returns. Registrations are live shell state: session stores do not keep them.

Mirage has no OS signals. Cancellation, `kill`, timeouts and closing a workspace or session stop the work without running cleanup, and they interrupt cleanup that is already running (bash runs `EXIT` on a `SIGTERM`). `trap` refuses every other event (`INT`, `TERM`, `ERR`, `DEBUG`, `RETURN`, ...) with `mirage: trap: NAME: not supported`, and `trap -l` the same way.

## Background job output (Python, Node and Browser)

A background job writes where the shell that started it writes, as it writes, so its lines and the foreground's come out in the order they were written, as in bash. `wait` prints none of a job's output, and `fg` prints only the job's command line.

A `shell()` call answers with what reached the session's terminal while the line ran. A job that writes after the call has returned has no line to show it, so that output waits and comes first in the next call on the session; bash prints it right away. With `sink`, the output goes to the console while a line runs, and between lines it waits the same way.

`$( )` and every pipeline stage but the last wait for the jobs they start, as bash reads a pipe until every writer has closed it. Inside `$( )`, `jobs` still lists the caller's jobs, but `wait` and `kill` reach only its own. `bash -c` sees only its own jobs.

## Node

### 1. `fs-monkey` only patches CJS `require('fs')`, not ESM `node:fs`

**The problem.** `patchNodeFs()` routes `fs` calls through the workspace VFS so that third-party libraries "just work" against mounted paths. It works by replacing `require.cache`'s `fs` entry (a CJS-only mechanism). ESM is fundamentally different:

* `import { readFile } from 'node:fs/promises'` resolves at parse time to V8's internal binding.
* There is no public hook to replace that binding after the fact.
* Loader hooks (`--loader=…`) could intercept the resolution, but they're a build-time decision, not a runtime monkey-patch.

**What this means in practice.**

```ts theme={null}
// ✅ Works, this is CJS and goes through require('fs')
const { readFileSync } = require('fs')
patchNodeFs(ws)
readFileSync('/data/x.txt')  // routes through RAM VFS

// ❌ Does not work, this is ESM and bypasses the patch
import { readFileSync } from 'node:fs'
patchNodeFs(ws)
readFileSync('/data/x.txt')  // hits the real filesystem, throws ENOENT
```

**Why Python doesn't hit this.** Python has no equivalent concept of "ESM vs CJS". `with Workspace() as ws:` swaps `builtins.open` and `sys.modules["os"]`: one set of mutable globals, one patch point, works for every caller.

**Workarounds.**

<Tabs>
  <Tab title="Use FUSE instead">
    If you want ESM-imported `node:fs` to see your mounted data, expose the workspace as a real filesystem. Mount FUSE, then every `fs` call, ESM or CJS, goes through the kernel.

    ```ts theme={null}
    import { readFile } from 'node:fs/promises'
    const ws = new Workspace({ '/data': new Mount(new RAMVFS(), { backend: MountBackend.FUSE }) })
    await ws.fuseReady()
    // The `/data` subtree is exposed at the mountpoint root, so paths are relative to it.
    const bytes = await readFile(`${ws.fuseMountpoint}/x.txt`)
    ```
  </Tab>

  <Tab title="Use Mirage's VFS API directly">
    The most reliable approach: call `ws.shell()` or the VFS methods. No magic, no monkey-patching, works from ESM or CJS.

    ```ts theme={null}
    const res = await ws.shell('cat /data/x.txt')
    const bytes = res.stdout
    ```
  </Tab>

  <Tab title="Restrict your code to CJS">
    If you really need `fs` patching to work, keep the consuming code in CJS. Note that most modern Node projects default to ESM, so this is a narrow escape hatch.

    ```js theme={null}
    // package.json: no "type": "module"
    const { readFileSync } = require('fs')
    ```
  </Tab>
</Tabs>

**Possible future fix.** A published Node loader hook that resolves `node:fs` through the workspace would remove the ESM limitation, at the cost of forcing consumers to opt into the loader (`node --loader @struktoai/mirage-node/loader main.mjs`). Not planned currently.

***

### 2. The FUSE mount is served by your process's event loop

**The problem.** `@zkochan/fuse-native` dispatches every FUSE request (`getattr`, `read`, …) as a callback on the mounting process's Node event loop. Any *synchronous* access to your own mountpoint from that same process (`readFileSync`, `statSync`, `execFileSync('cat', …)`) blocks the loop that must answer the request: the call deadlocks, the kernel eventually times the filesystem out, and later operations fail with `Device not configured` / `ENOTCONN`.

```ts theme={null}
const ws = new Workspace({ '/data': new Mount(new RAMVFS(), { backend: MountBackend.FUSE }) })
await ws.fuseReady()
const mp = ws.fuseMountpoint

// ✅ async APIs interleave with the FUSE callbacks
await readFile(`${mp}/x.txt`)
await promisify(execFile)('cat', [`${mp}/x.txt`])

// ❌ sync APIs deadlock the mount this process is serving
readFileSync(`${mp}/x.txt`)
execFileSync('cat', [`${mp}/x.txt`])
```

**Why Python doesn't hit this.** Python's FUSE loop runs on a dedicated thread, so the main thread can block on the mount freely.

**Workarounds.** Use async `fs` APIs and async subprocess spawns from the mounting process, or dedicate a child process to owning the mount (see `examples/typescript/fuse/helper.ts` for the pattern). External processes (a sandbox runtime, another terminal, your shell) are unaffected — the constraint only binds the process that created the mount.

***

### 3. Size-unknown API files stat as 0 bytes until first open

**The behavior.** API-backed mounts (Trello, Linear, Slack, MongoDB, …) return `stat.size = null` because the byte size isn't known until the API has been called. Over the FUSE mount these files behave like Linux `/proc` files: they stat as **0 bytes until first open**, and become fully readable the moment anything opens them. Mirage mounts with `direct_io` (the kernel reads to EOF regardless of the reported size; `@zkochan/fuse-native` doesn't expose the option, so Mirage appends it to the mount option string itself) and `attr_timeout=0` (post-open `fstat` returns the real size of the now-fetched content, kept warm in a 30-second cache).

What that means per tool:

| Tools | Behavior |
| - | - |
| `cat`, `grep`, `head`, `cp`, `md5sum`, `sed`, `sort` | correct content, always |
| `wc -c`, `tail -c` | correct (they fstat after open, which serves the real size) |
| `ls -l`, `du`, `find -size`, `test -s` | report 0 until the file has been opened recently |
| `tar`, `rsync`, `scp` | see 0 at stat time and copy empty content, exactly like `tar` over `/proc`; read the file first or use `cat`/`cp` based flows |

Mirage never reports a fake size and never fetches content during `stat`: returning real sizes eagerly would fire one API call per file on every `ls -l`. Earlier TS versions instead reported a fake 100 MiB size with zero-padded reads (`JSON.parse` of a FUSE-read file failed on the padding); that sentinel is gone and reads are byte-exact.

**Why Python behaves the same.** Unlike the other entries on this page, this one is not TS-specific: it is a design property of API-backed mounts, and Python's FUSE layer has identical semantics (see [Python FUSE setup](/python/setup/fuse#size-semantics-for-api-backed-files)). It is listed here because the stat-time zeros are the most common FUSE surprise in practice.

***

## Browser

The browser SDK runs entirely in-page: no kernel, no subprocesses, no Node `fs`. That removes the Node sections above (neither FUSE nor `fs-monkey` apply) but introduces its own constraints.

### 1. No FUSE

Browsers can't mount filesystems. The browser `Workspace` takes plain mounts: there is no `Mount` backend option and no `addFuseMount`. Use `ws.shell(...)` (virtual executor) and `ws.vfs.read`/`write` instead. Every builtin (`cat`, `grep`, `jq`, `awk`, `python3`, etc.) is reimplemented in-process, so most agent code paths work unchanged from Node.

### 2. OPFS quotas and persistence

`OPFSVFS` writes through the [Origin Private File System](https://web.dev/articles/origin-private-file-system). Two things to know:

* **Storage quota.** The browser sets per-origin quotas (typically a fraction of free disk, single-digit GB on most setups). Hitting it raises `QuotaExceededError`. Call `navigator.storage.estimate()` to inspect.
* **Eviction.** Origins that aren't [persisted](https://web.dev/articles/persistent-storage) can be cleared by the browser under storage pressure. For long-lived workspaces, request `navigator.storage.persist()` early.

### 3. CORS for HTTP-backed VFS

Mounts that hit third-party APIs (S3, GitHub, Linear, etc.) make `fetch` calls from the page. Anything not configured to allow your origin via CORS will fail with the usual browser error. Workarounds:

* **Browser-native auth flows.** mounts like Box, Dropbox, GDrive, GDocs ship PKCE OAuth examples that work entirely in-browser.
* **Pre-signed URLs.** For S3/R2/GCS, generate pre-signed URLs server-side and pass them in. The Mirage browser examples include a Vite dev-server `presigner` plugin as reference.
* **Same-origin proxy.** Stand up a tiny proxy on your own domain that forwards to the upstream API with the right auth headers.

### 4. Pyodide caches accessed files for each run

Pyodide's worker fetches workspace files on access and caches them for that
execution. An external edit to a cached file is not visible until the next
run. Without workers or `SharedArrayBuffer`, the fallback collects whole
mounts before execution, which can be expensive for large mounts.

See [Pyodide workspace file access](/typescript/runtime/python#workspace-file-access)
for browser requirements and limitations.

### 5. Some VFS drivers remain Node-only

SSH, LanceDB, Email, Disk, and the Hugging Face mounts are only exposed by `@struktoai/mirage-node` because their drivers need Node APIs or raw TCP. Importing them from `@struktoai/mirage-browser` is a build error.

Postgres and MongoDB do have browser implementations:

* Postgres uses `NeonPgDriver` over HTTP.
* MongoDB uses `HttpMongoDriver` and an HTTP proxy such as the bundled mongo-proxy example.

Those HTTP drivers still need a browser-reachable endpoint with the correct CORS policy. They are not drop-in replacements for raw Postgres or MongoDB socket connections.

***

## Quick reference

| Scenario | Runtime | Status |
| - | - | - |
| `patchNodeFs()` with `require('fs')` (CJS) | Node | ✅ works |
| `patchNodeFs()` with `import from 'node:fs'` (ESM) | Node | ❌ silently bypassed; use FUSE or the VFS API |
| FUSE `cat`/`readFile` of API-backed size-unknown files | Node | ✅ works (stats 0 until opened, like `/proc`) |
| `tar`/`rsync` of an unopened size-unknown file | Node | ❌ copies 0 bytes (stat-only tools; same in Python) — read the file first or use `cp` |
| Synchronous access to your own mount from the mounting process | Node | ❌ deadlocks; use async APIs or a child process |
| `ws.shell(...)` virtual builtins | Browser | ✅ works |
| `Workspace` with a fuse `Mount` | Browser | ❌ not supported; the browser `Workspace` takes plain mounts |
| OPFS reads/writes within quota | Browser | ✅ works |
| OPFS over quota | Browser | ❌ `QuotaExceededError`; check `navigator.storage.estimate()` |
| HTTP-backed mounts without CORS allow-list | Browser | ❌ blocked; use PKCE, presigned URLs, or a same-origin proxy |
| Python `open()` writes back through mount | Browser | ✅ on any engine |
| Postgres through `NeonPgDriver` | Browser | ✅ over HTTP; endpoint must allow the browser origin |
| MongoDB through `HttpMongoDriver` | Browser | ✅ through an HTTP proxy; direct MongoDB sockets are unavailable |
| Node-only VFS types (SSH, LanceDB, Email, Disk, Hugging Face, GridFS, FUSE) | Browser | ❌ Node APIs or raw TCP required |

The Node limitations are runtime-level, not Mirage design decisions. Python's equivalent behavior is strictly better in each case, so if a workflow absolutely requires ESM-level `fs` patching or large API-backed FUSE reads, consider whether the Python SDK fits better for that specific piece. The Browser limitations are by design (no kernel, no subprocess), so the workarounds there are about choosing the right runtime for the task.

## CPU-bound builtin processing

Line readers, cache drains, and `wc` processing periodically yield to the event loop. Large RAM files and warm cache reads therefore allow host timers and I/O callbacks to run. `Workspace.shell(command, { signal })` forwards caller cancellation to builtin input processing; Python provides the corresponding `cancel=asyncio.Event()` path and task cancellation.

File-cache fingerprints use incremental MD5: the Node package installs native hashing with 64 KiB updates, while browser/core uses JavaScript block batches. Both yield on a time budget; Python uses incremental `hashlib` updates.

These checkpoints are cooperative, not a hard execution-time bound. A synchronous regex call, native sort, whole-buffer allocation, or individual hash chunk can still occupy the thread until that operation returns. Custom handlers must observe cancellation themselves. Embeddings requiring isolation from arbitrary CPU work should run the workspace in a separate worker or process.

Cancellation itself has one seam. Python runs the whole line as one task, cancels it and joins it, so every await in the line unwinds before `execute` raises; the line's own flush and history record get the same 250 ms grace after the cancel and are cancelled too if a store holds them past it. A JavaScript promise cannot be cancelled, so TypeScript fires the signal, joins the tree for a short grace (250 ms) while responsive leaves close their producers and settle, then releases the caller. `execute` always rejects with an `AbortError` whose `cause` is the signal's reason. A leaf that does not observe the signal within the grace (a custom handler, a backend call with no signal parameter) keeps running in the background until it settles on its own; its output is dropped and it cannot change `$?` (the status door refuses a statement that settles after the release), but any write it completes still lands on the backend. A handler that loops over operands reaches its backend through slots (a generic-bound command such as `rm`, `cp` or `mkdir`) or through the op door (a namespace-routed one such as `chmod`, `touch` or `rm` of a link), and both refuse to start once the signal has fired, so a released line begins no further read or write between operands; only a custom handler that reaches its backend directly can. Python's cancelled task never reaches the next operand, and a Python handler or store must let `CancelledError` propagate: one that swallows both deliveries holds `execute` until it returns, as it would hold `asyncio.wait_for`, and a warning is logged while it does. A caller cancelled from outside (`asyncio.wait_for` around `execute`) gets the same grace as the event. Loading workspace state, parsing, routing, the fetch of a managed variable's secret, the history record and the session flush are raced against the signal as well, so a stalled state store or secret source cannot hold the caller either. A line is recorded in history once it has been parsed; an abort that lands while workspace state is still loading records nothing, in both languages. A command timeout is the line's own answer (exit 124), never an abort of the invocation: neither language writes the caller's signal or event. A background job, and every line it evaluates, runs without the caller's signal or event; only `kill` reaches it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.