UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

681 lines • 30.2 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 { 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