UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

459 lines • 20 kB
// ========= 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