@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
459 lines • 20 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 { classify } from "../../../errors/index.js";
import { isAborting } from "../../../errors/fs.js";
import { entryName } from "../../../utils/remnants.js";
import { normDir } from "../../../utils/slash.js";
import { parseMode } from "../../handles/mode.js";
import { MAX_URANDOM_BYTES, NOT_A_LINK, SCAN_LINK_DEPTH, SCAN_MAX_ENTRIES } from "./constants.js";
import { asGuestError, guestError } from "./errors.js";
import { childPaths } from "./list.js";
import { isDirRow, isRegularRow, statResult } from "./stat.js";
import { boundSystemClock } from "../../../utils/clock.js";
function pathArg(value) {
if (typeof value === 'string')
return value;
if (value !== null && typeof value === 'object' && 'path' in value) {
const p = value.path;
return typeof p === 'string' ? p : null;
}
return null;
}
/** Character count the way python's `len` counts: code points, not UTF-16 units. */
function textLength(data) {
return Array.from(String(data)).length;
}
function payloadBytes(data) {
if (data instanceof Uint8Array)
return data;
return new TextEncoder().encode(typeof data === 'string' ? data : '');
}
/** Match Python MontyFs's per-call entropy cap before allocating host memory. */
function urandom(value) {
const size = Number(value);
if (size > MAX_URANDOM_BYTES) {
throw Object.assign(new Error(`os.urandom() size exceeds max_urandom_bytes (${String(MAX_URANDOM_BYTES)})`), { name: 'MemoryError' });
}
if (!Number.isSafeInteger(size) || size < 0)
throw new TypeError('invalid os.urandom size');
const bytes = new Uint8Array(size);
// Web Crypto accepts at most 64 KiB per call, including in Node.
for (let offset = 0; offset < size; offset += 65_536) {
globalThis.crypto.getRandomValues(bytes.subarray(offset, Math.min(offset + 65_536, size)));
}
return bytes;
}
function timeZoneArg(value) {
if (value === null || typeof value !== 'object')
return null;
const marker = value;
if (marker.__monty_type__ !== 'TimeZone' || typeof marker.offsetSeconds !== 'number')
return null;
return {
offsetSeconds: marker.offsetSeconds,
...(typeof marker.name === 'string' ? { name: marker.name } : {}),
};
}
/**
* `seconds` since the epoch as monty's DateTime marker, which the engine
* turns into a real guest `datetime`. No timezone argument means a naive
* now in UTC, as monty's own clock answers; a TimeZone marker means an
* aware now in that offset.
*/
function dateTimeMarker(seconds, tz) {
const shifted = new Date((seconds + (tz?.offsetSeconds ?? 0)) * 1000);
const fields = {
__monty_type__: 'DateTime',
year: shifted.getUTCFullYear(),
month: shifted.getUTCMonth() + 1,
day: shifted.getUTCDate(),
hour: shifted.getUTCHours(),
minute: shifted.getUTCMinutes(),
second: shifted.getUTCSeconds(),
microsecond: shifted.getUTCMilliseconds() * 1000,
};
if (tz === null)
return fields;
return {
...fields,
offsetSeconds: tz.offsetSeconds,
...(tz.name !== undefined ? { timezoneName: tz.name } : {}),
};
}
function dateMarker(seconds) {
const now = new Date(seconds * 1000);
return {
__monty_type__: 'Date',
year: now.getUTCFullYear(),
month: now.getUTCMonth() + 1,
day: now.getUTCDate(),
};
}
// The calls that read or change content: only a path in the runtime's
// view may reach the adapter with one. Structural questions ask of any
// path; a mkdir checks the view itself, after its existence probe.
const CONTENT = new Set([
'open',
'Path.read_text',
'Path.read_bytes',
'Path.write_text',
'Path.write_bytes',
'Path.append_text',
'Path.append_bytes',
'Path.rmdir',
'Path.unlink',
'Path.rename',
]);
/** The predicates, which answer false where there is no filesystem. */
const PROBES = new Set(['Path.exists', 'Path.is_file', 'Path.is_dir', 'Path.is_symlink']);
/** The other calls this adapter serves, which refuse where there is none. */
const STRUCTURE = new Set(['Path.mkdir', 'Path.iterdir', 'Path.scan', 'Path.stat']);
/**
* Monty's OS callbacks: every path a guest names is the workspace's.
*
* This is monty's tier of the interception taxonomy: the engine calls
* one host callback per operation and takes back a value (or a promise
* of one) or NOT_HANDLED, which the sandbox raises as the call's
* default refusal. Every path goes to the file adapter, and nothing is
* kept aside: structure is open (a listing, whether a name is a
* directory or a link) and content goes only through the runtime's
* view (`RuntimeFiles.serves`: the announced mounts and what a link
* reaches), so a guest lists what a shell lists and reads and writes
* nothing the view withholds. The python twin answers the same way.
* Declining is reserved for an operation these callbacks do not implement.
*
* Monty hands the callbacks whole-file calls: an open, then reads of the
* whole file and appends of each new write. So an open applies its
* mode's effect on the mount (`RuntimeFiles.open`, closed again at once)
* and nothing else, and each write after it ships only its own bytes.
*
* Args:
* bits: the loaded engine's callback pieces (NOT_HANDLED sentinel
* and the MontyFileHandle an `open` answer must be).
* env: the run's environment, readable both ways python's monty
* spells it (`os.getenv` and `os.environ`).
* files: the execution's file adapter, or null outside a workspace,
* where every path is out of view.
*/
export class MontyFs {
bits;
notHandled;
fileHandle;
env;
files;
/**
* The workspace's system clock the run starts on, null for the host's:
* the engine calls back from native code, which the line's binding does
* not reach.
*/
clock = boundSystemClock();
constructor(bits, env, files) {
this.bits = bits;
this.notHandled = bits.NOT_HANDLED;
this.fileHandle = bits.MontyFileHandle;
this.env = env;
this.files = files;
}
wall() {
return this.clock === null ? Date.now() / 1000 : this.clock.now();
}
handle = (name, args, kwargs = {}) => {
if (name === 'os.getenv') {
// hasOwn, not `in`: the guest picks the key, so a name like
// `toString` must miss instead of leaking a host function.
const key = String(args[0]);
if (Object.hasOwn(this.env, key))
return this.env[key];
return args.length > 1 ? args[1] : null;
}
if (name === 'os.environ') {
// The engine asks for the whole mapping as one call; a plain
// object arrives in the guest as a dict, so `.get`, `[...]`,
// `in`, iteration and len all work, and a missing key raises
// KeyError. A copy, like python's environ: a guest that mutates
// it cannot reach the session's own env.
return { ...this.env };
}
// The clock callbacks: python's engine defaults these to the host
// clock, so declining them (a guest RuntimeError) was a divergence
// for any program that stamps its output.
if (name === 'datetime.now')
return dateTimeMarker(this.wall(), timeZoneArg(args[0]));
if (name === 'date.today')
return dateMarker(this.wall());
// Every `time` module clock asks here, naming itself: the wall clocks
// read the system clock; monotonic and perf_counter keep the host's.
if (name === 'time.time') {
const caller = typeof args[0] === 'string' ? args[0] : 'time.time';
return caller.startsWith('time.monotonic') || caller.startsWith('time.perf_counter')
? performance.now() / 1000
: this.wall();
}
if (name === 'os.urandom')
return urandom(args[0]);
// Everything below serves a path; the callbacks above need none.
const path = pathArg(args[0]);
if (path === null)
return this.notHandled;
// Lexical questions need no mount: resolve() is absolute() and '/'
// is the working directory, which is also what python's engine
// answers (a str, on both hosts).
if (name === 'Path.resolve' || name === 'Path.absolute') {
return path.startsWith('/') ? path : '/' + path;
}
const files = this.files;
if (files === null)
return this.unbound(name, path);
const out = CONTENT.has(name) && !files.serves(path)
? Promise.reject(guestError('ENOENT', path))
: this.op(name, path, args, kwargs, files);
if (!(out instanceof Promise))
return out;
// A mount words its refusals its own way; the guest catches the
// builtin CPython raises and may print its message.
const target = name === 'Path.rename' ? (pathArg(args[1]) ?? undefined) : undefined;
return out.catch((caught) => {
throw asGuestError(caught, path, target);
});
};
/** Outside a workspace there is no filesystem: probes answer false, the rest refuse. */
unbound(name, path) {
if (PROBES.has(name))
return false;
if (CONTENT.has(name) || STRUCTURE.has(name))
throw guestError('ENOENT', path);
return this.notHandled;
}
op(name, path, args, kwargs, files) {
switch (name) {
case 'open':
return this.open(path, typeof args[1] === 'string' ? args[1] : 'r', files);
case 'Path.read_bytes':
return files.read(path);
case 'Path.read_text':
return files.read(path).then((b) => {
try {
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(b);
}
catch (cause) {
throw Object.assign(new Error('invalid UTF-8', { cause }), {
name: 'UnicodeDecodeError',
guestCondition: 'unicode',
});
}
});
case 'Path.write_bytes':
case 'Path.write_text':
return this.write(path, args[1], files);
case 'Path.append_bytes':
case 'Path.append_text':
return this.append(path, args[1], files);
case 'Path.mkdir':
return this.mkdir(path, kwargs, files);
case 'Path.rmdir':
return files.rmdir(path).then(() => null);
case 'Path.unlink':
return files.unlink(path).then(() => null);
case 'Path.rename': {
const dst = pathArg(args[1]);
if (dst === null)
return this.notHandled;
return files.rename(path, dst).then(() => null);
}
case 'Path.iterdir':
return files.readdir(normDir(path), false).then((entries) => childPaths(path, entries.map((e) => e.path)));
case 'Path.scan':
return this.scan(path, kwargs, files);
// The predicates read the row the view shows: the mount's own,
// or a directory the workspace lists.
case 'Path.is_dir':
return files.viewStat(path).then((st) => st !== null && isDirRow(st));
case 'Path.is_symlink':
return this.isLink(path, files);
case 'Path.is_file':
return files.viewStat(path).then((st) => st !== null && isRegularRow(st));
case 'Path.exists':
return files.viewStat(path).then((st) => st !== null);
case 'Path.stat':
return files.viewStat(path).then((st) => {
if (st === null)
throw guestError('ENOENT', path);
return statResult(this.bits, st);
});
default:
return this.notHandled;
}
}
/**
* The subtree under `path` in one call, for walk, scandir and glob.
*
* One listing per directory, each entry kinded by the stat that
* listing already made, so a walk costs what `find` costs instead of
* the three probes per entry monty's default asks. The glob's pattern
* is only a hint, since monty filters the tree itself. A directory
* below `path` that will not list reads as empty, as it does on
* monty's own mounts; a tree past SCAN_MAX_ENTRIES raises
* RuntimeError, because a walk would swallow an OSError.
*
* Args:
* path: the directory to scan, or a file to describe.
* kwargs: `max_depth` (the deepest level to return, none for the
* whole tree) and `follow_symlinks` (descend into linked
* directories).
* files: the execution's file adapter.
*/
async scan(path, kwargs, files) {
const root = await files.viewStat(path);
if (root === null)
throw guestError('ENOENT', path);
const tree = [
['', isDirRow(root), isRegularRow(root), await this.isLink(path, files)],
];
const follow = kwargs.follow_symlinks === true;
const maxDepth = typeof kwargs.max_depth === 'number' ? kwargs.max_depth : follow ? SCAN_LINK_DEPTH : null;
const pending = isDirRow(root) ? [[path, '', 0]] : [];
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
const [directory, relative, depth] = next;
if (maxDepth !== null && depth >= maxDepth)
continue;
let rows;
try {
rows = await files.readdir(normDir(directory));
}
catch (err) {
if (depth === 0 || isAborting(err))
throw err;
console.debug(`monty scan: ${directory} did not list: ${String(err)}`);
continue;
}
for (const row of rows) {
const name = entryName(row.path);
const child = directory === '/' ? '/' + name : directory + '/' + name;
const entry = relative === '' ? name : relative + '/' + name;
const [isDir, isFile] = await this.kind(child, row, files);
const isLink = row.isLink === true;
tree.push([entry, isDir, isFile, isLink]);
if (tree.length > SCAN_MAX_ENTRIES) {
throw Object.assign(new Error(`directory scan exceeded ${String(SCAN_MAX_ENTRIES)} entries`), {
name: 'RuntimeError',
guestCondition: 'scan',
});
}
if (isDir && (follow || !isLink))
pending.push([child, entry, depth + 1]);
}
}
return tree;
}
/**
* Whether a listed entry is a directory and whether a regular file.
* The listing's own stat answers for a path in the view, except for a
* link, whose row is the link's and not what it reaches, and a row the
* listing could not stat. Those, and every path outside the view, ask
* the view as `Path.is_dir` would, so a file the view withholds (the
* history beside the mounts) is not offered as one. A stat that fails
* leaves the entry neither, CPython's rule inside `os.walk`.
*/
async kind(path, row, files) {
if (row.isLink !== true && files.serves(path)) {
if (row.mode !== undefined) {
const st = { size: row.size, isDir: row.isDir, mode: row.mode };
return [isDirRow(st), isRegularRow(st)];
}
if (row.isDir)
return [true, false];
}
try {
const reached = await files.viewStat(path);
return reached === null ? [false, false] : [isDirRow(reached), isRegularRow(reached)];
}
catch (err) {
if (isAborting(err))
throw err;
console.debug(`monty scan: ${path} did not stat: ${String(err)}`);
return [false, false];
}
}
/**
* Whether the name plane holds a symlink at `path`, asked through
* readlink. A refusal the backend did not mean as "no link here"
* comes out as itself (NOT_A_LINK), which is what CPython's own
* `Path.is_symlink` does.
*/
isLink(path, files) {
return files.readlink(path).then(() => true, (caught) => {
const condition = classify(caught);
if (condition === null || !NOT_A_LINK.has(condition))
throw caught;
return false;
});
}
async open(path, mode, files) {
// Handle first, as monty's own engine does: a malformed mode must
// raise before any side effect lands on the mount.
const handle = new this.fileHandle(path, mode);
const parsed = parseMode(mode);
const fd = await files.open(path, parsed);
let size = 0;
try {
if (parsed.append)
size = (await files.fstat(fd)).size;
}
finally {
await files.close(fd);
}
return parsed.append ? new this.fileHandle(path, mode, { position: size }) : handle;
}
/** Replace a file; the return is python's: characters for text, bytes for bytes. */
async write(path, data, files) {
const bytes = payloadBytes(data);
await files.write(path, bytes);
return typeof data === 'string' ? textLength(data) : bytes.length;
}
/**
* Send only the appended bytes; monty hands an append nothing else.
* Re-sending everything written so far turns a write loop quadratic,
* so a mount with its own append op carries just these bytes, and the
* adapter falls back to a whole-file write only for the mount without
* one. The return is python's: characters for text, bytes for bytes.
*/
async append(path, data, files) {
const tail = payloadBytes(data);
await files.append(path, tail);
return typeof data === 'string' ? textLength(data) : tail.length;
}
/**
* Create a directory, keeping pathlib's flags: `parents` rides through
* to the backend op, which takes it; `exist_ok` is answered here,
* since the op has no such argument and backends differ on whether
* creating an existing directory raises at all. `exist_ok` forgives
* an existing directory only — a file at the target still raises,
* pathlib's own rule.
*/
async mkdir(path, kwargs, files) {
const row = await files.viewStat(path);
if (row !== null && !isDirRow(row))
throw guestError('EEXIST', path);
if (row !== null) {
if (kwargs.exist_ok === true)
return null;
throw guestError('EEXIST', path);
}
if (!files.serves(path))
throw guestError('ENOENT', path);
await files.mkdir(path, kwargs.parents === true);
return null;
}
}
//# sourceMappingURL=fs.js.map