@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
672 lines • 27.9 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { epochToIso } from "../../../../utils/dates.js";
import { FileStat } from "../../../../types.js";
import { BLKSIZE, DIR_MODE, FILE_MODE, FLAGS, LINK_MODE, SEEK_CUR, SEEK_END, UMASK, } from "./constants.js";
import { errnoError } from "./errors.js";
import { classify } from "../../../../errors/index.js";
import { isMissingPath } from "../../../../errors/fs.js";
import { entryName } from "../../../../utils/remnants.js";
import { S_IFCHR, S_IFMT } from "../../../../utils/stat_view.js";
import { isUnclassified } from "../../../files.js";
import { openMode } from "../../../handles/mode.js";
import { posixStat } from "../../../stat.js";
import { NodeTable } from "./nodes.js";
// Emscripten's MEMFS makes these three at startup and its own stderr capture
// reopens /dev/stderr when a run ends (`API.restore_stderr`). Mounting mirage's
// /dev on top hid them, so that reopen raised ENOENT out of a filesystem
// callback nobody catches, and the unhandled rejection took the process down
// with it. Recreated here so the interpreter still finds them. rdev matches
// MEMFS's own numbering.
const STD_STREAM_RDEV = new Map([
['stdin', 6],
['stdout', 7],
['stderr', 8],
]);
// /dev/null's device number. It and the three streams above answer a read with
// EOF; only /dev/zero hands back an endless run of zeroes, and a stream that
// did the same would hang `open('/dev/stdin').read()` forever.
const NULL_RDEV = 0x103;
const EOF_ON_READ_RDEV = new Set([NULL_RDEV, ...STD_STREAM_RDEV.values()]);
function isCharDevice(mode) {
return (mode & S_IFMT) === S_IFCHR;
}
/**
* The metadata a `setattr` actually changes, or null when it changes
* none of it.
*
* Emscripten hands this callback more than a guest's chmod and utime:
* a stamp arrives beside a resize and beside a mode, and a write that
* moved no field would still journal an op. Sending one costs a
* dispatch and, worse, splits two writes the journal would otherwise
* coalesce. Comparing against the node costs one read.
*
* The one case comparing cannot catch is a create, whose finalizing
* chmod does lower a real mode; `PyodideFs.fresh` is what suppresses
* that one.
*
* `ctime` is never included: no POSIX call sets it directly, so a mount
* has nothing to write it to. `size` is not metadata here either, it is
* the resize branch's bytes.
*
* Args:
* node: the node as it stands before the write.
* attr: the fields Emscripten passed, times as epoch ms.
*/
export function changedAttrs(node, attr) {
const fields = {};
if (attr.mode !== undefined && (attr.mode & 0o7777) !== (node.mode & 0o7777)) {
fields.mode = attr.mode & 0o7777;
}
if (attr.atime !== undefined && attr.atime !== node.atime) {
fields.atime = epochToIso(attr.atime / 1000);
}
if (attr.mtime !== undefined && attr.mtime !== node.mtime) {
fields.mtime = epochToIso(attr.mtime / 1000);
}
return Object.keys(fields).length === 0 ? null : fields;
}
/**
* A name a repeat listing found, classified by its own stat as the
* first listing's file adapter classifies it, or left as the listing's row
* when that stat fails, the way the file adapter degrades one.
*/
function ownRow(sync, path, entry) {
if (entry.isDir)
return entry;
try {
return sync.stat(path);
}
catch (error) {
if (!isMissingPath(error))
console.warn(`mirage: cannot stat ${path}: ${String(error)}`);
return entry;
}
}
/** The owner a mount's row reports, kept on the node `getattr` reads; the
* owner and the group each on its own, since a chown may set either. */
function takeOwner(node, stat) {
if (typeof stat.uid === 'number')
node.uid = stat.uid;
if (typeof stat.gid === 'number')
node.gid = stat.gid;
}
/**
* An Emscripten filesystem backed by a mirage mount.
*
* This is the pyodide runtime's interception layer. Mounting it at a mount
* prefix puts mirage below the guest's syscall boundary, where every
* spelling of an operation (`open`, `os.open`, `pathlib`, `shutil`'s
* fd-relative walk) arrives as the same callback. Nothing is patched
* inside the interpreter.
*
* Callbacks are synchronous: lookups, reads and writes wait for the host
* through SyncVFS, which needs the worker. A file's stream reads and
* writes through a handle on the run's open-file core, which holds its
* bytes as every other runtime's open files are held; names and metadata
* enter the journal and land at once, in guest order.
*
* The node table lives in `NodeTable`; what is left here is one method
* per Emscripten callback, plus the errno translation only this layer
* performs.
*/
export class PyodideFs {
sync;
deferred;
streams;
type;
host;
errno;
journal;
nodes;
mountOf;
// The file node FS.open just created, whose finalizing chmod is the
// filesystem's own and not a guest metadata write. Cleared by the
// first setattr, so it can never outlive the create it belongs to.
fresh = null;
/**
* Args:
* host: the Emscripten FS namespace (`pyodide.FS`).
* errno: Emscripten's errno table (`pyodide.ERRNO_CODES`).
* journal: the write-ahead log guest mutations are recorded on.
* prefix: the mount prefix this filesystem serves.
* mountOf: the mirage mount owning a path (`RuntimeFiles.mountOf`).
* One mountpoint serves every mirage mount nested under its
* prefix, so this is the only boundary fact left to check ops
* against; Emscripten's own cross-mount checks cannot see it.
* sync: the worker's blocking way to the mounts.
* deferred: the refusals the mount gave a path whose call has not
* come yet, shared by every mountpoint as the journal is.
* streams: the file streams that hold a core handle, shared the same
* way, which the run's end closes.
*/
constructor(host, errno, journal, prefix, mountOf, sync, deferred = new Map(), streams = new Set()) {
this.sync = sync;
this.deferred = deferred;
this.streams = streams;
this.host = host;
this.errno = errno;
this.journal = journal;
this.mountOf = mountOf;
const nodeOps = {
getattr: this.getattr.bind(this),
setattr: this.setattr.bind(this),
lookup: this.lookup.bind(this),
mknod: this.mknod.bind(this),
rename: this.rename.bind(this),
unlink: this.unlink.bind(this),
rmdir: this.rmdir.bind(this),
readdir: this.readdir.bind(this),
symlink: this.symlink.bind(this),
readlink: this.readlink.bind(this),
};
const streamOps = {
open: this.streamOpen.bind(this),
close: this.streamClose.bind(this),
read: this.streamRead.bind(this),
write: this.streamWrite.bind(this),
llseek: this.llseek.bind(this),
getattr: (stream) => this.attrOf(stream.node, stream.opened ?? this.nodes.pathOf(stream.node), this.host.isFile(stream.node.mode) ? this.handleOf(stream) : undefined),
};
this.nodes = new NodeTable(host, prefix, nodeOps, streamOps);
this.type = { mount: this.nodes.mount.bind(this.nodes) };
}
getattr(node) {
return this.attrOf(node, this.nodes.pathOf(node));
}
/**
* One node's stat, its inode named by `path`.
*
* Args:
* node: the node to describe.
* path: the name its inode is reported under.
* fh: the core handle a stream stats through, which counts what it
* holds; absent stats the node's path.
*/
attrOf(node, path, fh) {
// The core's row, so one path reports one inode whoever asks, what an
// open handle wrote and has not landed counted and the policies
// judging it as a stat of its path. A device is the tree's own.
let row = {
size: 0,
isDir: false,
mode: node.mode,
uid: node.uid ?? 0,
gid: node.gid ?? 0,
rdev: node.rdev,
atimeMs: node.atime,
mtimeMs: node.mtime,
};
if (!isCharDevice(node.mode)) {
const sync = this.sync;
row = this.readThrough([], () => fh === undefined ? sync.stat(this.nodes.pathOf(node)) : sync.fstat(fh));
if (node.unclassified === true)
this.classifyNode(node, row);
if (row.isDir)
row = { ...row, size: BLKSIZE };
}
const posix = posixStat(row, path, '/');
return {
dev: posix.dev,
ino: posix.ino,
mode: posix.mode,
nlink: node.unlinked === true ? 0 : posix.nlink,
uid: posix.uid,
gid: posix.gid,
rdev: posix.rdev,
size: posix.size,
atime: new Date(posix.atimeMs),
mtime: new Date(posix.mtimeMs),
ctime: new Date(node.ctime),
blksize: posix.blksize,
blocks: posix.blocks,
};
}
setattr(node, attr) {
// Read the changes before applying them, and only outside a create:
// the same callback carries a guest's chmod and the chmod Emscripten
// itself performs to finalize a new file, and the two are the same
// shape. `fresh` is what tells them apart; the comparison then drops
// a stamp write that changes nothing.
const finalizing = this.fresh === node;
this.fresh = null;
// Read before the mode below is applied. A character device is not mount
// content, so none of its metadata is a guest write: Emscripten chmods one
// right after creating it, and journaling that queued a setattr against
// the real mount for a node the mount does not have.
const isDevice = isCharDevice(node.mode);
const fields = finalizing || isDevice ? null : changedAttrs(node, attr);
const size = isDevice ? undefined : attr.size;
if (fields !== null && node.unlinked !== true) {
// A link's own attrs go to the link, since the tree resolved to
// the link node itself and the target's row is not what changed.
if (this.host.isLink(node.mode))
fields.nofollow = true;
this.journal.markSetattr(this.nodes.pathOf(node), fields);
}
// A resize goes as a truncate to the new length. Recording here rather
// than at close is what makes a bare `os.truncate(path, n)`, which
// opens no handle at all, reach the mount.
if (size !== undefined && node.unlinked !== true)
this.journal.markTruncate(this.nodes.pathOf(node), size);
if (node.unlinked !== true)
this.settle(this.nodes.pathOf(node));
else if (size !== undefined)
this.resizeHeld(node, size);
// Once the mount took the guest's own edit, the backend row the policies
// are shown carries it; the times Emscripten filled in stay out.
if (fields !== null && node.unlinked !== true && node.source !== undefined) {
node.source = new FileStat(node.source).with({
...(fields.mode !== undefined ? { mode: fields.mode } : {}),
...(fields.mtime !== undefined ? { modified: fields.mtime } : {}),
...(fields.atime !== undefined ? { atime: fields.atime } : {}),
});
}
// The chmod that finalizes a create carries the mode the create asked
// for, so the umask comes off it as it did off the node.
if (attr.mode !== undefined)
node.mode = finalizing ? attr.mode & ~UMASK : attr.mode;
if (attr.atime !== undefined)
node.atime = attr.atime;
if (attr.mtime !== undefined)
node.mtime = attr.mtime;
if (attr.ctime !== undefined)
node.ctime = attr.ctime;
}
/**
* Resize a file whose name is gone through a stream that writes it: its
* bytes are only what the core's handles on it hold.
*
* Args:
* node: the file with no name left.
* size: the length it is left at.
*/
resizeHeld(node, size) {
const writes = FLAGS.O_WRONLY | FLAGS.O_RDWR;
for (const stream of this.streams) {
const fh = stream.fh;
if (stream.node !== node || fh === undefined || (stream.flags & writes) === 0)
continue;
this.readThrough([], () => {
this.sync.ftruncate(fh, size);
});
return;
}
}
lookup(parent, name) {
const sync = this.sync;
let found = this.nodes.childOf(parent, name);
if (found === undefined) {
const path = this.nodes.pathOf(parent) + '/' + name;
found = this.readThrough([path], () => {
let stat;
try {
stat = sync.stat(path);
}
catch (error) {
const rdev = STD_STREAM_RDEV.get(name);
if (this.nodes.pathOf(parent) === '/dev' &&
rdev !== undefined &&
classify(error) === 'ENOENT') {
return this.nodes.makeNode(parent, name, S_IFCHR | 0o666, rdev);
}
throw error;
}
return this.placeEntry(parent, name, stat);
});
}
return found;
}
mknod(parent, name, mode, rdev) {
// A character device is not mount content. pyodide makes one of its own
// at runtime -- `API.capture_stderr` calls FS.createDevice, which lands
// here as mknod -- and journaling it queued a write of /dev/capture_stderr
// against the real mount, which then failed on replay. Devices live in
// this tree only.
if (isCharDevice(mode)) {
const device = this.nodes.makeNode(parent, name, mode);
device.rdev = rdev;
return device;
}
const path = this.nodes.pathOf(parent) + '/' + name;
if (this.host.isDir(mode)) {
this.journal.markMkdir(path);
this.settle(path);
}
// The create is what carries a file that is made and never written
// (`Path.touch()`, `open(p,'w').close()`) through to the mount. The
// open FS.open makes next takes it back and creates the file itself,
// one open with O_CREAT as on every other entry point; a mknod no open
// follows lands it with the next call, or at the run's end.
else
this.journal.markCreate(path);
const node = this.nodes.makeNode(parent, name, mode & ~UMASK);
node.rdev = rdev;
// FS.open finalizes a new file with a chmod of its own, right here
// and on this node. Only a file is marked: a directory gets no such
// call, so a marker left on one would swallow the guest's next chmod
// instead.
if (!this.host.isDir(mode))
this.fresh = node;
return node;
}
rename(node, newDir, newName) {
const from = this.nodes.pathOf(node);
const to = this.nodes.pathOf(newDir) + '/' + newName;
// A nested mirage mount is served through this same mountpoint, so
// Emscripten's kernel cannot see the boundary; refuse the crossing
// here, before the tree moves and the journal records a rename the
// post-run replay would reject anyway.
if (this.mountOf(from) !== this.mountOf(to)) {
throw errnoError(this.host, this.errno, 'EXDEV');
}
const found = this.nodes.childOf(newDir, newName);
const replaced = found !== node ? found : undefined;
this.journal.markRename(from, to);
this.settle(from, to);
if (replaced !== undefined)
this.orphan(replaced);
this.nodes.move(node, newDir, newName);
}
unlink(parent, name) {
const path = this.nodes.pathOf(parent) + '/' + name;
const node = this.nodes.childOf(parent, name);
this.journal.markUnlink(path);
this.settle(path);
if (node !== undefined)
this.orphan(node);
this.nodes.detach(parent, name);
}
/**
* A file whose name the mount removed or replaced. One a stream has open
* reads and writes on through the core, which keeps its bytes; marked
* only once the mount took the change, so a refused removal leaves it
* named.
*
* Args:
* node: the file that lost its name.
*/
orphan(node) {
if ((node.opens ?? 0) > 0)
node.unlinked = true;
}
rmdir(parent, name) {
if (this.readdir(this.lookup(parent, name)).length > 2) {
throw errnoError(this.host, this.errno, 'ENOTEMPTY');
}
const path = this.nodes.pathOf(parent) + '/' + name;
this.journal.markRmdir(path);
this.settle(path);
this.nodes.detach(parent, name);
}
/**
* The directory's names, asking the mount for any it has gained.
*
* The first listing is classified, since Emscripten's getdents reads
* every name's kind off its node. A node is never re-asked about once
* placed, so a later listing asks for names alone and stats only the
* names the tree lacks, rather than classifying every entry again.
*/
readdir(node) {
const sync = this.sync;
this.readThrough([], () => {
const dir = this.nodes.pathOf(node) + '/';
const again = node.listed === true;
for (const entry of sync.readdir(dir, !again)) {
const name = entryName(entry.path);
if (this.nodes.childOf(node, name) !== undefined)
continue;
this.placeEntry(node, name, again ? ownRow(sync, dir + name, entry) : entry);
}
node.listed = true;
});
return ['.', '..', ...this.nodes.childNames(node)];
}
invalidate() {
this.nodes.invalidate();
}
/**
* Send the journal to the mount now, in guest order. An entry the mount
* refuses fails the call that touched its path with the mount's errno,
* this call or that file's next one (its close), as a deferred write
* error does; until then nothing more for that path reaches the mount,
* and every other file's entries still land. Without a worker there is
* no mount to send to yet, and the journal waits for the run's end.
*
* Args:
* paths: the paths this call touched.
*/
settle(...paths) {
const sync = this.sync;
const sendable = (m) => !this.deferred.has(m.path) && !(m.kind === 'rename' && this.deferred.has(m.dst));
let pending = this.journal.takeMutations().filter(sendable);
try {
while (pending.length > 0) {
const failure = sync.flush(pending);
if (failure === undefined)
break;
const rest = pending.length - failure.skipped;
const failed = pending[rest - 1];
if (failed === undefined)
break;
this.deferred.set(failed.path, failure);
pending = pending.slice(rest).filter(sendable);
}
}
catch (error) {
throw errnoError(this.host, this.errno, classify(error) ?? 'EIO');
}
for (const path of paths) {
const failure = this.deferred.get(path);
if (failure === undefined)
continue;
this.deferred.delete(path);
throw errnoError(this.host, this.errno, failure.code ?? 'EIO');
}
}
readThrough(paths, read) {
this.settle(...paths);
try {
return read();
}
catch (error) {
throw errnoError(this.host, this.errno, classify(error) ?? 'EIO');
}
}
placeEntry(parent, name, stat) {
const target = stat.isLink
? this.sync.readlink(this.nodes.pathOf(parent) + '/' + name)
: undefined;
const mode = stat.isLink ? LINK_MODE : (stat.mode ?? (stat.isDir ? DIR_MODE : FILE_MODE));
const node = this.nodes.makeNode(parent, name, mode, stat.rdev ?? 0);
if ('source' in stat)
node.source = stat.source;
if (stat.mtimeMs !== undefined)
node.atime = node.mtime = node.ctime = stat.mtimeMs;
if (stat.atimeMs !== undefined)
node.atime = stat.atimeMs;
takeOwner(node, stat);
if (target !== undefined)
node.link = target;
// Emscripten's getdents looks up every name it lists, so a row the
// file adapter could not classify still needs a node; it goes in as a
// regular file and is asked about before its first stat.
if (isUnclassified(stat))
node.unclassified = true;
return node;
}
/**
* Restamp a node placed from an unclassified row with what the mount
* said it is.
*
* The listing that placed it swallowed a failed stat so the rest of
* the directory could list; the guest's own stat is where that
* failure belongs, so that stat asks the mount rather than answering
* from the guess. Its answer turns the node into a directory if that
* is what it is.
*
* Args:
* node: the node to classify.
* stat: the row the guest's stat got.
*/
classifyNode(node, stat) {
node.unclassified = false;
node.source = stat.source;
this.nodes.retype(node, stat.mode);
node.rdev = stat.rdev ?? 0;
node.atime = node.mtime = node.ctime = stat.mtimeMs ?? 0;
if (stat.atimeMs !== undefined)
node.atime = stat.atimeMs;
takeOwner(node, stat);
}
/**
* Create a symlink node and record it for the mounts.
*
* A link is namespace state, which is exactly why this can be served:
* the op reaches the node table rather than a backend, so a link lands
* on an s3 or notion mount whose store has no such thing. The target
* is stored as typed and never resolved here.
*
* Args:
* parent: directory the link is created in.
* name: the link's own name.
* target: what it points at, verbatim.
*/
symlink(parent, name, target) {
const path = this.nodes.pathOf(parent) + '/' + name;
this.journal.markSymlink(path, target);
this.settle(path);
const node = this.nodes.makeNode(parent, name, LINK_MODE);
node.link = target;
return node;
}
/**
* The target of a symlink node.
*
* Args:
* node: the node to read.
*/
readlink(node) {
if (!this.host.isLink(node.mode))
throw errnoError(this.host, this.errno, 'EINVAL');
return node.link ?? '';
}
/**
* Open a file's stream on the run's core, which judges, holds and lands
* it as it does every runtime's open files. Emscripten made the open's
* create and truncate before it got here; what is left is how the
* stream reads and writes.
*/
streamOpen(stream) {
const node = stream.node;
const path = this.nodes.pathOf(node);
stream.opened = path;
if (this.host.isFile(node.mode)) {
const creating = this.journal.takeCreate(path);
try {
this.handleOf(stream, creating);
}
catch (error) {
// A create the mount refused leaves no file behind.
if (creating)
this.nodes.detach(node.parent, node.name);
throw error;
}
}
node.opens = (node.opens ?? 0) + 1;
}
/**
* The core handle a file's stream reads and writes through. One a run's
* end closed opens again, as long as its file still has a name: the
* core held the file for that run only. A guest that exited
* (`os._exit`, caught) has no files left. `create` makes the file the
* open's own mknod named.
*/
handleOf(stream, create = false) {
if (this.journal.sealed)
throw errnoError(this.host, this.errno, 'EBADF');
if (stream.fh !== undefined)
return stream.fh;
if (stream.node.unlinked === true)
throw errnoError(this.host, this.errno, 'EBADF');
const path = this.nodes.pathOf(stream.node);
const mode = { ...openMode(stream.flags, FLAGS), create };
const fh = this.readThrough([path], () => this.sync.open(path, mode));
stream.fh = fh;
this.streams.add(stream);
return fh;
}
/** Land what the stream wrote and let its handle go. */
streamClose(stream) {
const node = stream.node;
node.opens = Math.max(0, (node.opens ?? 1) - 1);
const fh = stream.fh;
this.streams.delete(stream);
delete stream.fh;
try {
if (node.unlinked === true)
this.settle();
else
this.settle(this.nodes.pathOf(node));
}
finally {
if (fh !== undefined)
this.readThrough([], () => {
this.sync.close(fh);
});
}
}
streamRead(stream, buffer, offset, length, position) {
if (length === 0)
return 0;
const node = stream.node;
if (isCharDevice(node.mode)) {
if (EOF_ON_READ_RDEV.has(node.rdev))
return 0;
buffer.fill(0, offset, offset + length);
return length;
}
const fh = this.handleOf(stream);
const out = this.readThrough([], () => this.sync.pread(fh, position, length)).subarray(0, length);
buffer.set(out, offset);
return out.length;
}
streamWrite(stream, buffer, offset, length, position) {
if (length === 0)
return 0;
if (isCharDevice(stream.node.mode))
return length;
const fh = this.handleOf(stream);
// A copy: the guest's buffer is a view into WASM memory, and a message
// carries a view's whole buffer.
const bytes = buffer.slice(offset, offset + length);
this.readThrough([], () => {
this.sync.pwrite(fh, position, bytes);
});
return length;
}
llseek(stream, offset, whence) {
let position = offset;
if (whence === SEEK_CUR)
position += stream.position;
else if (whence === SEEK_END && this.host.isFile(stream.node.mode)) {
const fh = this.handleOf(stream);
position += this.readThrough([], () => this.sync.fstat(fh)).size;
}
if (position < 0)
throw errnoError(this.host, this.errno, 'EINVAL');
return position;
}
}
//# sourceMappingURL=fs.js.map