@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
681 lines • 30.2 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 { IOResult, OpReport } from "../io/types.js";
import { closeQuietly, ensureStream } from "../io/stream.js";
import { finishRecord, OpTimer, record } from "../observe/context.js";
import { SystemClock } from "../utils/clock.js";
import { asyncContextIsolatesTasks } from "../utils/async_context.js";
import { FileType, PathSpec } from "../types.js";
import { isEnotdir, isMissingPath } from "../errors/fs.js";
import { callsAs, currentCaller, effectivePathMode, getCurrentSession, runWithCaller, sessionVisibility, } from "../context/session_context.js";
import { NamespaceReader } from "./dispatcher/reader.js";
// TypeScript has no package-private: the class assigns these from its static
// block, where its private members are in reach, and `readerOf`, `operandsOf`,
// `pathMode` and `withCaller` are the doors through them.
let readerOfFiles;
let pathModeOfFiles;
let operandsOfFiles;
let callerFilesOf;
// The op's byte count for recording: the result first, else the input
// (write payloads travel as the first positional argument). Mirrors
// Python's _payload_bytes.
function payloadBytes(result, args) {
if (result instanceof Uint8Array)
return result.byteLength;
for (const arg of args) {
if (arg instanceof Uint8Array)
return arg.byteLength;
}
return 0;
}
/**
* A streamed answer, recorded once it ends: the dispatcher stamps the report with
* the bytes the stream carried when it ends, so the record waits for that
* rather than counting none. The caller runs it to its empty first step,
* inside the `try`, so a stream closed before its first pull is still closed
* and recorded. Mirrors Python's `_recorded`.
*/
async function* recorded(stream, record) {
const iterator = stream[Symbol.asyncIterator]();
try {
yield new Uint8Array();
for (;;) {
const next = await iterator.next();
if (next.done === true)
return;
yield next.value;
}
}
finally {
await iterator.return?.();
await record();
}
}
/**
* The typed file API: `ws.vfs`, `session.vfs` and a command's, CLI leaf's or
* runtime's `ctx.vfs`.
*
* Every op delegates to the workspace dispatcher, so `ws.vfs` walks the
* same pipeline as a shell command: link follow, session grants,
* admission policies, cache read-through, namespace structure, and
* post-write invalidation all fire once, at that one dispatcher. The facade
* keeps only what is its own: the typed surface and the op ledger it
* records on — the ledger lives here, not on the workspace, which is what
* lets `MountCore` take one `Files` instead of reaching through a whole
* `Workspace`. The ledger is read on `ws.vfs`, a `WorkspaceFiles`. Mirrors
* Python's `Files`.
*
* The facade runs as one session, `sessionId`, through `bind`: every
* op is judged under that session's profile (hides, mount modes,
* grants) exactly as a shell line in it would be, so an agent whose
* file tool reads through `ws.vfs` is confined the way its shell is.
* null names the workspace's default session as it is when the op
* runs, since a snapshot load can rename it. A session already bound
* when the op arrives (a command's own runtime, a kernel mount serving
* one session) is kept, so the facade never widens the caller's view,
* and the record names the session that judged the op. `Session`
* derives a facade for another session over the same ledger.
*
* Every op also takes a trailing `sessionId` for the one-call case,
* the way `Workspace.shell` does. One rule decides between them: a
* shell line *sets* the session, an op *inherits* it. So the argument
* is the session to run as when no line is already running, and the
* line's session wins when one is, which is what keeps a handler
* reaching the dispatcher from widening the view it was given.
*/
export class Files {
static {
readerOfFiles = (files) => files.reader();
pathModeOfFiles = (files, path, follow) => {
const mode = (bound) => {
const reader = files.reader();
const virtual = follow ? reader.follow(path) : reader.followParent(path);
const owner = reader.owner(virtual);
if (owner === null)
return Promise.resolve(null);
return Promise.resolve(effectivePathMode(virtual, owner.prefix, owner.mode, bound ?? undefined));
};
return files.bind === null ? mode(null) : files.bind(files.sessionId, mode);
};
operandsOfFiles = (files) => files.follow;
callerFilesOf = (files, changes) => files.forCaller(changes);
}
dispatchFn;
namespace;
sink;
bind;
dispatchAs;
caller;
/** The session this facade runs as; null for the workspace's default. */
sessionId;
/** The op ledger this facade records on; `WorkspaceFiles.records` reads it. */
recorded;
follow;
ledger;
clock;
/**
* The mount whose command scope these files run in, null for a run across
* mounts; undefined where nothing said one, which the async context
* answers instead (`servedBy`).
*
* @internal
*/
served;
/**
* @param dispatch the workspace dispatcher every op goes to.
* @param namespace the reader of the namespace for the session an op runs
* as (`Namespace.readers`); null for files outside a workspace, which see
* no link and no mount under the bound session's hides.
* @param sink receives each record with the session it ran as.
* @param options what a derived facade carries over.
*/
constructor(dispatch, namespace = null, sink = null, options = {}) {
this.dispatchFn = dispatch;
this.namespace = namespace;
this.sink = sink;
this.bind = options.bind ?? null;
this.dispatchAs = options.dispatchAs ?? null;
this.caller = options.caller ?? null;
this.sessionId = options.sessionId ?? null;
this.recorded = options.records ?? [];
this.follow = options.follow ?? null;
this.ledger = options.ledger ?? true;
this.served = options.served;
this.clock = options.clock ?? new SystemClock();
}
options() {
return {
bind: this.bind,
dispatchAs: this.dispatchAs,
sessionId: this.sessionId,
records: this.recorded,
clock: this.clock,
};
}
/**
* The same facade run as another session, over the same ledger, so
* the workspace-wide account stays one list.
*
* @internal The mechanism behind `Session.vfs`, not an entry point of
* its own: a host binds a session with `ws.session(id)` (creating it
* when the id is new) or `new Session(ws, id)` (adopting one
* that exists), so there is one way to say it rather than two.
* TypeScript has no package-private, so this stays reachable; it is
* not part of the supported surface. Python spells it
* `Files._for_session`.
*/
forSession(sessionId) {
return new Files(this.dispatchFn, this.namespace, this.sink, {
...this.options(),
sessionId,
});
}
/**
* The same facade with `changes` said of every op's caller: a file
* tool's call, the unit an op-level answer covers, so an approval one op
* is answered by is claimed for the call's other ops and a call running
* at the same time is never taken for this one; a dry run's trace; or a
* policy method's deciding mark. Said on each op, not bound in the async
* context, which without task isolation another task would read. Reached
* through `withCaller`.
*/
forCaller(changes) {
return new Files(this.dispatchFn, this.namespace, this.sink, {
...this.options(),
caller: { ...this.caller, ...changes },
});
}
/**
* The same files said to run in `mount`'s command scope, null for a run
* across mounts: what `mountRoot` and `crossesMounts` read, tied to the
* command's run, where an async context without task isolation answers
* whichever task bound last. The ops are unchanged.
*
* @internal Said by a mount for each command it runs, and by a run across
* mounts; Python's task context carries the caller instead.
*/
servedBy(mount) {
return new Files(this.dispatchFn, this.namespace, this.sink, {
...this.options(),
caller: this.caller,
follow: this.follow,
ledger: this.ledger,
served: mount,
});
}
/**
* The same files over another dispatcher, keeping no ledger.
*
* @internal Behind `captureBinding`; Python spells it `Files._over`.
* @param dispatch the dispatcher the calls go to.
*/
over(dispatch) {
return new Files(dispatch, this.namespace, null, {
sessionId: this.sessionId,
follow: this.follow,
ledger: false,
});
}
/**
* The namespace as the session an op would run as sees it: the workspace's,
* else, for files outside one, no link and no mount under the bound
* session's hides. Mirrors Python's `Files._reader`.
*/
reader() {
if (this.namespace === null) {
return new NamespaceReader(null, () => [], this.sessionId, () => sessionVisibility());
}
return this.namespace(this.sessionId);
}
/**
* The mount prefix serving `path`, without a trailing slash: the one
* `RuntimeFiles.mountOf` gives a guest, where a walk up a tree stops, as git
* stops looking for `.git` at a filesystem boundary. null for a path the
* session cannot see. A path a show carve-out opens inside a hidden mount
* names that mount, whose root the path itself runs through. Mirrors
* Python's `Files.mount_of`.
*/
mountOf(path) {
return this.reader().mountOf(path);
}
/** Whether `path` is the root of a mount the session sees. Mirrors Python's
* `Files.is_mount`. */
isMount(path) {
return this.reader().isMount(path);
}
/**
* The mount roots strictly under `path`, sorted, without a trailing slash,
* as `findmnt --submounts` lists them.
*
* A mount the session hides still stands where it is: whatever removes or
* renames the folder holding it would take it along. It is reported by the
* nearest folder above it the session sees, `path` itself when nothing
* between is visible, so a caller refuses that folder without learning the
* mount's name. Mirrors Python's `Files.submounts`.
*/
submounts(path) {
return this.reader().submounts(path);
}
/**
* `Workspace.dispatch` as this facade runs it: as its session, and
* recorded on the ledger. The dispatcher `RuntimeFiles` drives for node's
* patched `fs` (`patchNodeFs`), a facade caller like FUSE, and a
* handler's for any op its typed methods do not spell; bound, so a handler
* can hand it on. The IOResult is empty unless this facade keeps no
* ledger. Mirrors Python's `Files.dispatch`.
*/
dispatch = async (name, path, args = [], kwargs = {}, report) => {
if (!this.ledger)
return this.dispatchFn(name, PathSpec.fromStrPath(path, undefined, '/'), args, kwargs, report);
return [await this.through(name, path, args, kwargs), new IOResult()];
};
/**
* Run one op through the workspace dispatcher and record it.
*
* The dispatcher owns the whole pipeline (follow, grants, gates, cache,
* structure, invalidation). On a line this workspace runs, the op records
* where the line records, so its records are the line's; anywhere else
* (outside a line, or on another workspace's) it records into its own
* recording, handed to this ledger and the sink once the op ends, a
* stream's once it closes. What records it is what records a line's
* calls: the backend and the dispatcher, with the tokens they read. An op
* nothing below recorded (a stat, a listing, a warm cache hit, a
* synthetic namespace answer) is recorded here, on the path the
* dispatcher's report says it ran on, its links followed. Mirrors
* Python's Files._call.
*/
async through(op, path, args = [], kwargs = {}, sessionId) {
if (!this.ledger)
return (await this.dispatch(op, path, args, kwargs))[0];
const timer = new OpTimer(this.clock);
const report = new OpReport();
const spelled = typeof path === 'string' ? path : path.virtual;
const outer = currentCaller();
// Without task isolation (a browser) a backend's record lands in
// whichever call bound last, so a call cannot tell its own records from
// an overlapping call's: there it records the op itself, from the
// dispatcher's report, straight onto the ledger.
const isolated = asyncContextIsolatesTasks;
// Only a line this workspace runs is joined: another workspace's line
// would take this workspace's records.
const own = !isolated || outer.recording?.ledger === this.recorded
? null
: { records: [], mountId: null, ledger: null, clock: this.clock };
// The op's own list gathers every record made below it; a command the
// op runs in on that line (a handler reaching `ws.vfs`) is charged them
// once the op ends.
const mine = [];
const enclosing = isolated && own === null ? (outer.commandSink?.[0] ?? null) : null;
const caller = isolated
? { ...outer, ...(own === null ? {} : { recording: own }), commandSink: [mine, mine] }
: outer;
// The record names the session the op ran as: noted inside the
// bind, since the facade's own id may be null (the default) and a
// session bound by the caller is kept over it.
let seen = null;
const run = (bound = null) => {
seen = bound?.sessionId ?? getCurrentSession()?.sessionId ?? null;
// Resolved against the root: the dots simplified away for the op,
// the typed spelling kept for the walk that proves them.
const typed = PathSpec.fromStrPath(path, undefined, '/');
const dispatch = bound === null || this.dispatchAs === null ? this.dispatchFn : this.dispatchAs(bound);
const explicit = this.caller === null ? undefined : { ...currentCaller(), ...this.caller };
return dispatch(op, typed, args, kwargs, report, explicit);
};
// `fallback` false hands over what was recorded below and makes no
// record of its own: an admission check wrote nothing, and a write
// record without a fingerprint would drop the path's snapshot pin.
const settle = async (answer, fallback = true) => {
const ran = report.path ?? spelled;
const owner = this.reader().owner(ran);
const source = owner === null ? null : (report.source ?? owner.kind);
const bytes = report.bytes ?? payloadBytes(answer, args);
if (!isolated) {
if (!fallback || !report.completed || source === null)
return;
const rec = finishRecord(op, ran, source, bytes, timer);
this.recorded.push(rec);
if (this.sink !== null)
await this.sink(rec, this.sessionFor(seen));
return;
}
if (fallback && mine.length === 0 && report.completed && source !== null) {
callsAs(caller, record)(op, ran, source, bytes, timer);
}
enclosing?.push(...mine);
if (own === null || own.records.length === 0)
return;
const records = own.records.splice(0);
for (const item of records)
this.recorded.push(item);
if (this.sink === null)
return;
const session = this.sessionFor(seen);
for (const rec of records)
await this.sink(rec, session);
};
let result;
try {
const bound = sessionId ?? this.sessionId;
const go = () => this.bind === null ? run() : this.bind(bound, run);
const [value] = await (isolated ? runWithCaller(caller, go) : go());
result = value;
}
catch (err) {
// Anything thrown after the op ran (a postVfs deny, a hard
// output cap, a bookkeeping failure) suppresses the result, not
// the effect, so observation must reflect the op before the
// error propagates. The dispatcher stamps the report at the moment of
// completion, so even a foreign error the dispatcher never defined
// leaves the transfer on the books.
await settle(null);
throw err;
}
finally {
// A write owns its source even when it never reaches the dispatcher
// (an unknown session refused at the bind).
if (op === 'write' || op === 'append' || op === 'pwrite')
await closeQuietly((args[0] ?? kwargs.data ?? null));
}
if ((op === 'write' && kwargs.admit_only === true) || 'retained' in kwargs) {
await settle(undefined, false);
return result;
}
if (result !== null && typeof result === 'object' && Symbol.asyncIterator in result) {
const stream = recorded(result, () => settle(null));
await stream.next();
return stream;
}
await settle(result);
return result;
}
/** The session id a record carries: the one the op ran as, else this
* facade's own, else the unbound entry point's empty id. */
sessionFor(seen) {
return seen ?? this.sessionId ?? '';
}
async read(path, options = {}, sessionId) {
const kwargs = options.raw === true ? { filetype: null } : {};
const offset = options.offset ?? 0;
const size = options.size ?? null;
if (offset !== 0 || size !== null)
Object.assign(kwargs, { offset, size });
if (options.stream !== true) {
return (await this.through('read', path, [], kwargs, sessionId));
}
return ensureStream((await this.through('read', path, [], { ...kwargs, stream: true }, sessionId)));
}
async write(path, data, sessionId) {
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
await this.through('write', path, [bytes], {}, sessionId);
}
/**
* Append bytes or an async byte source through the dispatcher: a VFS's
* `append` takes either, and one with no `append` is read and rewritten.
*/
async append(path, data, sessionId) {
await this.through('append', path, [data], {}, sessionId);
}
/**
* Write bytes at an offset, keeping the rest of the file (the python
* facade's `pwrite`). pwrite(2): the bytes outside the window stay, a gap
* past the end reads back as zeros, and a missing file is created. It is
* one write at the dispatcher, so a session that may write the file and not
* read it can still do it. A stream lets the path's hold go while its next
* bytes are pulled, as a streamed write does.
*/
async pwrite(path, data, offset, sessionId) {
await this.through('pwrite', path, [data, offset], {}, sessionId);
}
async readdir(path, sessionId) {
return (await this.through('readdir', path, [], {}, sessionId)) ?? [];
}
/**
* One path's row. `nofollow` reports a trailing symlink itself rather
* than its target (lstat), so a dangling link is a name that is there.
* `fresh` asks the backend past the mount's listing index and reconciles
* the index and file cache with its answer.
*/
async stat(path, sessionId, opts = {}) {
const kwargs = {
...(opts.nofollow === true ? { nofollow: true } : {}),
...(opts.fresh === true ? { fresh: true } : {}),
};
return (await this.through('stat', path, [], kwargs, sessionId));
}
// The three probes below answer "is this path there?", so only a path that
// does not resolve may read back as false: absent, outside every mount, or
// under a plain file (ENOTDIR). An auth failure, a timeout, or a backend
// bug is not an answer to that question: swallowing it would let a caller
// act on a false "missing" (overwrite, recreate, skip). Mirrors Python's
// `(FileNotFoundError, NotADirectoryError, NoMountError)` swallow set.
async exists(path, sessionId) {
try {
await this.stat(path, sessionId);
return true;
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err))
return false;
throw err;
}
}
async isDir(path, sessionId) {
try {
const s = await this.stat(path, sessionId);
return s.type === FileType.DIRECTORY;
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err))
return false;
throw err;
}
}
async isFile(path, sessionId) {
try {
const s = await this.stat(path, sessionId);
return s.type !== FileType.DIRECTORY;
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err))
return false;
throw err;
}
}
async mkdir(path, sessionId) {
await this.through('mkdir', path, [], {}, sessionId);
}
async create(path, sessionId) {
await this.through('create', path, [], {}, sessionId);
}
/**
* Create a namespace symlink at `path`.
*
* Routed through the dispatcher like every write: session grants and
* admission policies fire on the link's turf, and the write lands on
* the ledger. The target is stored verbatim as typed. Throws EEXIST
* when something is already at `path` (a file, a directory, another
* link, a mount root): symlink(2) never overwrites, and the dispatcher is
* the layer that can see both planes to tell. Throws ENOENT when the
* directory `path` would sit in is absent, and ENOTDIR when a
* non-directory stands there or above it. Mirrors Python's
* Files.symlink.
*/
async symlink(path, target, sessionId) {
await this.through('symlink', path, [], { target }, sessionId);
}
/** The stored target of the link at `path`; EINVAL when not a link. */
async readlink(path, sessionId) {
return (await this.through('readlink', path, [], {}, sessionId));
}
/**
* Write metadata fields, natively where the backend can hold them.
*
* Every field is passed, unset ones as undefined, because the dispatcher
* reads the whole set and stores in the namespace overlay whatever
* the backend cannot keep. A mount with no setattr op therefore still
* answers: a chmod on an s3 or dropbox mount lands in the name plane
* and stat reports it back. Stored, not enforced; the mount mode is
* the access control. Returns what the backend could not keep.
* Mirrors Python's Files.setattr.
*/
async setattr(path, attrs = {}, sessionId) {
const { nofollow = false, ...fields } = attrs;
return (await this.through('setattr', path, [], { ...fields, nofollow }, sessionId));
}
/**
* One extended attribute's value. The node table answers with what a
* caller set. `nofollow` reads a link entry's own
* attributes. Throws ENODATA when the path has no such attribute.
* Mirrors Python's Files.getxattr.
*/
async getxattr(path, name, opts = {}, sessionId) {
const kwargs = { name, nofollow: opts.nofollow === true };
return (await this.through('getxattr', path, [], kwargs, sessionId));
}
/** Every extended attribute name a path carries, sorted. */
async listxattr(path, opts = {}, sessionId) {
const kwargs = { nofollow: opts.nofollow === true };
return (await this.through('listxattr', path, [], kwargs, sessionId));
}
/**
* Store an extended attribute on a path's namespace node, so it works
* on every backend and moves with a rename. `create` refuses with
* EEXIST when it is set (XATTR_CREATE) and `replace` with ENODATA when
* it is not (XATTR_REPLACE).
* Mirrors Python's Files.setxattr.
*/
async setxattr(path, name, value, opts = {}, sessionId) {
const kwargs = {
name,
value,
create: opts.create === true,
replace: opts.replace === true,
nofollow: opts.nofollow === true,
};
await this.through('setxattr', path, [], kwargs, sessionId);
}
/** Drop an extended attribute; ENODATA when it is not set. */
async removexattr(path, name, opts = {}, sessionId) {
await this.through('removexattr', path, [], { name, nofollow: opts.nofollow === true }, sessionId);
}
async truncate(path, length, sessionId) {
await this.through('truncate', path, [length], {}, sessionId);
}
async unlink(path, sessionId) {
await this.through('unlink', path, [], {}, sessionId);
}
async rmdir(path, sessionId) {
await this.through('rmdir', path, [], {}, sessionId);
}
/**
* Rename a file or directory within one mount.
*
* Both ends must resolve to the same mount: a mount is a filesystem
* boundary, and the dispatcher answers EXDEV across two (ENOENT when a
* parent directory is missing), which a kernel-facing whole-workspace FUSE
* mount needs so `mv` between two backends falls back to its copy+unlink
* path. Mirrors Python's Files.rename.
*/
async rename(src, dst, sessionId) {
await this.through('rename', src, [PathSpec.fromStrPath(dst, undefined, '/')], {}, sessionId);
}
async listFiles(path, sessionId) {
const entries = await this.readdir(path, sessionId);
const files = [];
for (const fullPath of entries) {
if (await this.isFile(fullPath, sessionId)) {
files.push(fullPath.slice(fullPath.lastIndexOf('/') + 1));
}
}
return files;
}
}
/**
* `ws.vfs`: the files the host holds, with what only the host asks of them.
*
* The op ledger every ledgered facade appends to (`records`, with the
* network/cache split derived from it). The `Files` a handler, a CLI leaf, a
* runtime or `session.vfs` gets is a facade of its own, on which the ledger
* would report only that facade, so it is a plain `Files`. Mirrors Python's
* `WorkspaceFiles`, which also answers the mount questions FUSE and the
* runtime's `open()` interception ask; here the workspace's registry answers
* those.
*/
export class WorkspaceFiles extends Files {
/**
* The op ledger: every facade op lands here, and the executor appends each
* shell line's ops too, so this is the one workspace-wide account (Python's
* `WorkspaceFiles.records`).
*/
get records() {
return this.recorded;
}
/** Total bytes transferred over the network. */
get networkBytes() {
let total = 0;
for (const r of this.recorded)
if (!r.isCache)
total += r.bytes;
return total;
}
/** Total bytes served from cache. */
get cacheBytes() {
let total = 0;
for (const r of this.recorded)
if (r.isCache)
total += r.bytes;
return total;
}
}
/**
* The namespace as the session a command's files run as sees it, for the
* helpers that walk it (`NamespaceReader`); outside a workspace, no link and
* no mount.
*
* The dispatcher's reader, never one of the facade's own: the link and mount
* tables are the namespace's, and the reader filters every answer by the rule
* the dispatcher refuses a call by. Mirrors Python's `reader_of`.
*/
export function readerOf(files) {
return readerOfFiles(files);
}
/**
* The mode a write at `path` meets, for the session `files` runs as
* (`effectivePathMode`): asked under the binding its ops run under, so a
* default session's profile counts, at the path a link leads to, where the
* write lands, unless `follow` is off. Null where no mount owns it. Mirrors
* Python's `path_mode`.
*/
export function pathMode(files, path, follow = true) {
return pathModeOfFiles(files, path, follow);
}
/**
* The line's operand resolution a command's globs give their matches, which a
* command's files carry; null outside a line. Mirrors Python's `operands_of`.
*/
export function operandsOf(files) {
return operandsOfFiles(files);
}
/**
* The same files with `changes` said of every op's caller: a file tool's call,
* a dry run's trace or a policy method's deciding mark. Never a handler's: the
* dispatcher trusts the caller, so one that dropped its rule gate would read
* past a path rule scoped to its command. Python's task context carries the
* caller instead.
*/
export function withCaller(files, changes) {
return callerFilesOf(files, changes);
}
//# sourceMappingURL=files.js.map