UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

752 lines • 28.1 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 { dottedSpelling, resolvePath } from "./utils/path.js"; import { rstripSlash, stripSlash } from "./utils/slash.js"; /** * What a mount lets commands do, a cumulative ladder. READ lists and * reads. WRITE also creates, changes and deletes. EXEC also lets the * interpreter commands (python3, js) run code: a script file named as * their operand must sit on an EXEC mount, while inline code and -m need * one somewhere in the workspace (imports are not checked by path). * Shell scripts need no EXEC, since the shell checks each of their * commands like a typed one; a ./script whose shebang names python3 * reaches python3, which does check. */ export const MountMode = Object.freeze({ READ: 'read', WRITE: 'write', EXEC: 'exec', }); /** * How a mount is exposed to the outside world. * * `workspace` is the default: the mount lives only inside mirage's own filesystem * and is reached through the command surface, with nothing registered with * the kernel. `fuse` and `fskit` additionally expose it as a real mountpoint. * * `fskit` is macOS 15.4+ only and needs no kernel extension. It has no * `direct_io` equivalent, so it serves correct reads only for mounts that * set `sizesAlwaysKnown`; the mount-time guard warns about mounts whose * size-unknown files will read as empty. Writes are also limited: appends and * metadata ops persist, but the macFUSE FSKit shim flushes pages a file did * not already have (a new file, or truncate-then-write) as NUL bytes (pinned * in `integ/fuse/truth_fskit.json`). There is deliberately no `auto`: * auto-selecting fskit would silently degrade every API-backed mount. * * `nfs` mounts through the operating system's own NFS client, so nothing is * installed: mirage serves NFSv3 on a local port and runs the system `mount`. * Linux and macOS; Linux needs root to mount. It is read-only for now, and * like fskit it reads only as far as the size a file reports, so it serves * mounts that set `sizesAlwaysKnown`. */ export const MountBackend = Object.freeze({ WORKSPACE: 'workspace', FUSE: 'fuse', FSKIT: 'fskit', NFS: 'nfs', }); /** Backends that register a real mountpoint with the kernel. */ export const KERNEL_BACKENDS = Object.freeze([ MountBackend.FUSE, MountBackend.FSKIT, MountBackend.NFS, ]); export const MOUNT_MODE_RANK = Object.freeze({ [MountMode.READ]: 1, [MountMode.WRITE]: 2, [MountMode.EXEC]: 3, }); /** The weaker of two mount modes on the READ < WRITE < EXEC lattice. */ export function weakerMode(a, b) { return MOUNT_MODE_RANK[a] <= MOUNT_MODE_RANK[b] ? a : b; } export const DEFAULT_VISIBILITY = Object.freeze({ paths: null, shown: null, vars: null, processes: 'session', }); const MOUNT_MODE_ALIASES = Object.freeze({ r: MountMode.READ, rw: MountMode.WRITE, rwx: MountMode.EXEC, }); /** * Coerce a mount mode, accepting cumulative filesystem aliases. * * The mode ladder is cumulative (exec implies write implies read), so * only the cumulative spellings `r`, `rw`, `rwx` alias the modes; * bit-style forms like `w` or `x` are rejected. */ export function parseMountMode(value) { const alias = MOUNT_MODE_ALIASES[value]; if (alias !== undefined) return alias; if (Object.values(MountMode).includes(value)) return value; throw new Error(`invalid mount mode: '${value}'`); } /** * How a mount decides whether cached bytes may be served. * * FRESH revalidates against the backend's content token before serving a * cached copy; BOUNDED serves without revalidating, within the staleness * bound the mount declares. * * PINNED names the content a commit's fingerprint records. There is no * version layer to pin to, so a mount declaring it is refused at mount time * rather than quietly degraded to head: the vocabulary is published, so * someone will type it, and an informative refusal costs one branch over a * generic invalid-value error. */ export const ReadPolicy = Object.freeze({ FRESH: 'fresh', BOUNDED: 'bounded', PINNED: 'pinned', }); /** * Whether replacements carry the version they were based on. Unconditional * existing-file rewrites are last-write-wins. Shared rewrites that observed * a missing file require atomic create-only under either policy. * Conditional writes also protect the version the mount last saw. Staged * names a staging layer not implemented here and is refused at mount time. * Mirrors Python's `WritePolicy`. */ export const WritePolicy = Object.freeze({ UNCONDITIONAL: 'unconditional', CONDITIONAL: 'conditional', STAGED: 'staged', }); /** * What a backend's cached listings can be checked against under fresh. * * NONE: nothing, so a listing the running command did not write itself is * listed again. MOUNT: one version covers every listing of the mount, and a * stat of the mount root answers it. FOLDER: each listing carries its own * folder's version, and a stat of that folder answers it. The stored version * and the stat's fingerprint must be the same kind of token, since the gate * compares them with `===`. */ export const ListingVersion = Object.freeze({ NONE: 'none', MOUNT: 'mount', FOLDER: 'folder', }); /** Maximum lifetime in seconds for cached bodies and listings. */ export const DEFAULT_READ_TTL = 600; /** * The policy a mount takes when it declares none, and the one pinned onto the * three synthetic anchors (`/dev`, the history view, the implicit `/` root). * * Those three are installed outside `normalizeMounts`, so they never meet the * capability verdict; none of them caches reads, so inheriting a workspace-level * `fresh` would stamp on them exactly the combination the verdict refuses. */ export const DEFAULT_READ_SPEC = Object.freeze({ policy: ReadPolicy.BOUNDED, ttl: DEFAULT_READ_TTL, }); /** * Behaviour when a remote VFS's live fingerprint differs from the * value recorded at snapshot time. */ export const DriftPolicy = Object.freeze({ /** Raise ContentDriftError on first mismatch. */ STRICT: 'strict', /** Skip drift checks entirely. */ OFF: 'off', }); /** * Behaviour when a command's output exceeds its limit cap. * TRUNCATE returns the truncated bytes + a notice on stderr. * ERROR returns no stdout and exits 1 with the same notice. */ export const OnExceed = Object.freeze({ ERROR: 'error', TRUNCATE: 'truncate', }); function preferError(values) { return values.some((v) => v === OnExceed.ERROR) ? OnExceed.ERROR : OnExceed.TRUNCATE; } function minBound(values) { const bounds = values.filter((v) => v !== null); return bounds.length > 0 ? Math.min(...bounds) : null; } function minPositive(values) { const positives = values.filter((v) => v !== null && v > 0); return positives.length > 0 ? Math.min(...positives) : null; } function tightestWalk(values) { const present = values.filter((v) => v !== null); const caps = present.filter((v) => v >= 0); if (caps.length > 0) return Math.min(...caps); return present.length > 0 ? -1 : null; } /** * A bound on a result: the policy layer's limit arm and the shape * every cap config parses into. Carries its fields inline (the Deny * precedent: an action is its payload). `kind` is the wire * discriminant; `aggr` is the composition law (AND to the tightest * per bound, ANY on error mode) and `layered` the resolution law (each * field from the most specific layer that sets it). `walkLimit` caps the * entries one walk lists on a mount, -1 for no cap. */ export class Limit { kind = 'limit'; maxBytes; maxLines; timeoutSeconds; walkLimit; /** The fields the limit names, null among them; the rest inherit. Mirrors Python's model_fields_set. */ fieldsSet; exceed; constructor(init = {}) { const maxBytes = init.maxBytes ?? null; const maxLines = init.maxLines ?? null; const timeoutSeconds = init.timeoutSeconds ?? null; const walkLimit = init.walkLimit ?? null; if (maxBytes !== null && (!Number.isInteger(maxBytes) || maxBytes < 0)) { throw new TypeError(`maxBytes must be a non-negative integer, got ${String(maxBytes)}`); } if (maxLines !== null && (!Number.isInteger(maxLines) || maxLines < 0)) { throw new TypeError(`maxLines must be a non-negative integer, got ${String(maxLines)}`); } if (timeoutSeconds !== null && (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0)) { throw new TypeError(`timeoutSeconds must be a non-negative number, got ${String(timeoutSeconds)}`); } if (walkLimit !== null && (!Number.isInteger(walkLimit) || walkLimit < -1)) { throw new TypeError(`walkLimit must be an integer of at least -1, got ${String(walkLimit)}`); } this.maxBytes = maxBytes; this.maxLines = maxLines; this.timeoutSeconds = timeoutSeconds; this.walkLimit = walkLimit; this.exceed = init.onExceed ?? null; this.fieldsSet = new Set(Object.keys(init).filter((key) => init[key] !== undefined)); } get onExceed() { return this.exceed ?? OnExceed.TRUNCATE; } /** * Each field from the first limit that names it, most specific first: a * layer naming one bound inherits the others from the layers below it, and * one naming a bound as null lifts it. Returns null when nothing is * configured. Mirrors Python's Limit.layered. */ static layered(limits) { const present = [...limits].filter((s) => s !== null); if (present.length === 0) return null; const init = {}; for (const key of LIMIT_FIELDS) { const layer = present.find((s) => s.fieldsSet.has(key)); if (layer !== undefined) Object.assign(init, { [key]: layer.named(key) }); } return new Limit(init); } named(key) { return key === 'onExceed' ? this.onExceed : this[key]; } /** * Aggregate several limits using each field's declared rule. * * Reads the rules off LIMIT_AGGR rather than naming the fields here, so a * new bound composes as soon as it is declared — the same property Python * gets from walking `model_fields` for each field's `Aggr(rule)`. Returns * null when nothing is configured. Used wherever bounds stack (policy * composition, cross-mount fan-out, layered configs). */ static aggr(limits) { const present = [...limits].filter((s) => s !== null); if (present.length === 0) return null; const init = {}; for (const key of Object.keys(LIMIT_AGGR)) { if (present.some((s) => s.fieldsSet.has(key))) Object.assign(init, { [key]: LIMIT_AGGR[key](present) }); } return new Limit(init); } } const LIMIT_FIELDS = [ 'maxBytes', 'maxLines', 'timeoutSeconds', 'walkLimit', 'onExceed', ]; /** * Each bound's composition law, keyed by field. * * The mapped type is what makes this a table rather than a second copy of the * field list: declaring a new bound on Limit without adding its rule here is a * compile error, where the old inline literal would have silently dropped it. */ const LIMIT_AGGR = { maxBytes: (present) => minBound(present.map((s) => s.maxBytes)), maxLines: (present) => minBound(present.map((s) => s.maxLines)), timeoutSeconds: (present) => minPositive(present.map((s) => s.timeoutSeconds)), walkLimit: (present) => tightestWalk(present.map((s) => s.walkLimit)), onExceed: (present) => preferError(present.map((s) => s.onExceed)), }; export const VFSName = Object.freeze({ DISK: 'disk', S3: 's3', RAM: 'ram', GITHUB: 'github', WANDB: 'wandb', LINEAR: 'linear', GCAL: 'gcal', GDOCS: 'gdocs', GSHEETS: 'gsheets', GSLIDES: 'gslides', GDRIVE: 'gdrive', ONEDRIVE: 'onedrive', SHAREPOINT: 'sharepoint', DROPBOX: 'dropbox', BOX: 'box', AIRTABLE: 'airtable', SLACK: 'slack', DISCORD: 'discord', GMAIL: 'gmail', TRELLO: 'trello', MONGODB: 'mongodb', GRIDFS: 'gridfs', NOTION: 'notion', LANGFUSE: 'langfuse', JAEGER: 'jaeger', SSH: 'ssh', REDIS: 'redis', GCS: 'gcs', OCI: 'oci', R2: 'r2', EMAIL: 'email', OPFS: 'opfs', SUPABASE: 'supabase', POSTGRES: 'postgres', LANCEDB: 'lancedb', CHROMA: 'chroma', DIFY: 'dify', MEM0: 'mem0', QDRANT: 'qdrant', HF_BUCKETS: 'hf_buckets', HF_DATASETS: 'hf_datasets', HF_MODELS: 'hf_models', HF_SPACES: 'hf_spaces', NEXTCLOUD: 'nextcloud', DATABRICKS_VOLUME: 'databricks_volume', TIDB_FILESYSTEM: 'tidb_filesystem', MINIO: 'minio', CEPH: 'ceph', SEAWEEDFS: 'seaweedfs', WASABI: 'wasabi', BACKBLAZE: 'backblaze', DIGITALOCEAN: 'digitalocean', TENCENT: 'tencent', ALIYUN: 'aliyun', SCALEWAY: 'scaleway', QINGSTOR: 'qingstor', HISTORY: 'history', BIN: 'bin', }); /** * How a command resolves a command-line operand's last component when it is * a symlink: never (lstat(2): `rm`, `stat`), always (open(2): `cat`, * `stat -L`), or only once a stat shows it leads to a directory (`ls` with no * option that settles it). */ export const Follow = Object.freeze({ NONE: 'none', LAST: 'last', DIRECTORY: 'directory', }); /** * What one command line does, as its own command reads its options: the * admission gate, the router and the fan-out ask this instead of reading the * options themselves. `follows` is how its operands' last component * resolves; `walks` that its traversal enters the mounts below its operands * (`find`, `du`, `grep -r`; not `du -x`); `readsSubtree` that it reads below * the paths it names, whether or not it crosses a mount (`du -x`, `tar -c`, * `cp -r`); `writes` that it may change files, false only when its options * leave a writing command nothing to change (`sort` without `-o`, `tar -t`); * `subtree` that an operand is a whole subtree it moves or removes (`rm`, * `rmdir`, `mv`); `moveTarget` the directory a move names by option * (`mv -t DIR`), as typed, null when it names none. Mirrors Python's * `LineFacts`. */ export class LineFacts { follows; walks; readsSubtree; writes; subtree; moveTarget; constructor(init = {}) { this.follows = init.follows ?? Follow.LAST; this.walks = init.walks ?? false; this.readsSubtree = init.readsSubtree ?? false; this.writes = init.writes ?? true; this.subtree = init.subtree ?? false; this.moveTarget = init.moveTarget ?? null; Object.freeze(this); } /** A copy with some fields replaced, the mirror of Python's `dataclasses.replace`. */ with(update) { return new LineFacts({ follows: this.follows, walks: this.walks, readsSubtree: this.readsSubtree, writes: this.writes, subtree: this.subtree, moveTarget: this.moveTarget, ...update, }); } } export const FileType = Object.freeze({ DIRECTORY: 'directory', FILE: 'file', SYMLINK: 'symlink', CHAR_DEVICE: 'char_device', BLOCK_DEVICE: 'block_device', FIFO: 'fifo', SOCKET: 'socket', }); /** * A regular file's content shape: the rendering hint (file/ls color). * * Only meaningful for a FILE; a directory or symlink carries none. Not a * node kind: nothing branches control flow on it. Mirrors `ContentType` * in `mirage/types.py`. */ export const ContentType = Object.freeze({ TEXT: 'text', BINARY: 'binary', JSON: 'json', CSV: 'csv', IMAGE_PNG: 'image/png', IMAGE_JPEG: 'image/jpeg', IMAGE_GIF: 'image/gif', ZIP: 'application/zip', GZIP: 'application/gzip', PDF: 'application/pdf', }); // FileStat.extra key holding a symlink's target, verbatim as it was // typed. A link has no backend inode, so this is the only place the // target travels with the stat row. export const LINK_TARGET_KEY = 'link_target'; // FileStat.extra key holding a synthetic device's logical [major, minor]. export const DEVICE_NUMBERS_KEY = 'device_numbers'; // FileStat.extra key marking a file whose bytes change on their own (a // generated document): a kernel mount reads it fresh on every read rather // than holding what an earlier read returned. export const LIVE_KEY = 'mirage.live'; export class FileStat { name; size; modified; fingerprint; revision; type; content; mode; uid; gid; atime; ctime; birthtime; dev; extra; constructor(init) { this.name = init.name; this.size = init.size ?? null; this.modified = init.modified ?? null; this.fingerprint = init.fingerprint ?? null; this.revision = init.revision ?? null; this.type = init.type; // content is a FILE's rendering hint; a directory or symlink has // none. null on a FILE means "unknown", which is allowed. const content = init.content ?? null; if (content !== null && init.type !== FileType.FILE) { throw new Error(`content must be null for ${init.type}, got ${content}`); } this.content = content; this.mode = init.mode ?? null; this.uid = init.uid ?? null; this.gid = init.gid ?? null; this.atime = init.atime ?? null; this.ctime = init.ctime ?? null; this.birthtime = init.birthtime ?? null; this.dev = init.dev ?? null; this.extra = init.extra ?? {}; Object.freeze(this); } // Copy with some fields replaced, the mirror of Python's // `model_copy(update=...)`. Callers that rename a row (ls printing an // operand as typed, a link row taking the link's name) must use this // rather than re-listing fields at the call site: a hand-written list // silently drops whatever it forgot, which is how link ownership went // missing. This is the one place the field list is repeated, and it // sits next to the declarations so an added field is hard to miss. with(update) { return new FileStat({ name: this.name, size: this.size, modified: this.modified, fingerprint: this.fingerprint, revision: this.revision, type: this.type, content: this.content, mode: this.mode, uid: this.uid, gid: this.gid, atime: this.atime, ctime: this.ctime, birthtime: this.birthtime, dev: this.dev, extra: this.extra, ...update, }); } } /** * A directory's listing as a backend answers it, when one fetch proves more * than the directory's own children. * * A backend's `readdir` answers a plain list of its children's stats, or this * when the fetch says more: a day of messages that also lists its `files/` * subdirectory, a board whose lists come with their cards. The dispatcher * keeps every listing it is handed, so entering a seeded directory costs no * second identical fetch. * * - `entries`: the listed directory's own children, each filled in as `stat` * would fill it. * - `seeds`: descendant listings the same fetch proved, keyed by path relative * to the listed directory (`files`, `2026-01-05/Report__17`). * - `partial`: the entries are a filtered or truncated view (a glob the * backend narrowed, a fetch it did not finish), so they are kept as entries * but not as the directory's listing. `seeds` stay full listings of the * children it did report. * - `window`: the fetch was capped (the newest N messages, the last N days), * so the entries are everything to show but prove nothing absent: a child * that drops out of the window has not been deleted. Kept as listings that * evict nothing. * - `version`: the version the listing was read at, for a backend that * declares a `listingVersion`; a `fresh` mount checks it before serving the * listing again. */ export class DirListing { entries; seeds; partial; window; version; constructor(init) { this.entries = init.entries; this.seeds = init.seeds ?? new Map(); this.partial = init.partial ?? false; this.window = init.window ?? false; this.version = init.version ?? null; Object.freeze(this); } } export const FileChangeKind = Object.freeze({ CREATE: 'create', UPDATE: 'update', DELETE: 'delete', MOVE: 'move', UNKNOWN: 'unknown', }); export class FileMetadata { fingerprint; size; modified; constructor(init = {}) { this.fingerprint = init.fingerprint ?? null; this.size = init.size ?? null; this.modified = init.modified ?? null; Object.freeze(this); } } export class FileEvent { kind; path; timestamp; previousPath; metadata; constructor(init) { this.kind = init.kind; this.path = init.path; this.timestamp = init.timestamp; this.previousPath = init.previousPath ?? null; this.metadata = init.metadata ?? null; Object.freeze(this); } } export class Delta { changes; checkpoint; constructor(init) { this.changes = Object.freeze([...init.changes]); this.checkpoint = init.checkpoint; Object.freeze(this); } } export const OverflowPolicy = Object.freeze({ COLLAPSE: 'collapse', DROP_OLDEST: 'drop_oldest', ERROR: 'error', }); // How a mount's capacity relates to a df-style report. QUOTA: real // total/used/available are known (a real filesystem, a provider that // exposes a storage quota, or a mount's capacity). ELASTIC: no fixed size // (object stores that grow without a quota). NA: no filesystem-capacity // concept (message/table surfaces). UNKNOWN: bounded but not cheaply // measurable / not reported yet. // df renders real numbers for QUOTA and a literal `-` for the rest — never a // fabricated total. export const CapacityState = { QUOTA: 'quota', ELASTIC: 'elastic', NA: 'na', UNKNOWN: 'unknown', }; export class PathSpec { virtual; directory; vfsPath; pattern; resolved; // The word's spelling: as typed for relative words, the absolute path // for everything else (defaults to `virtual`). rawPath; // Absolute spelling before dot normalization; excluded from identity. dotted; // What the kernel walk already answered for an operand it cannot // resolve at all, known before the command runs: ENOENT for the empty // name, whose `virtual` reads as the working directory, and ELOOP for // one a symlink loop stands in, which `followPaths` leaves unrewritten. // Every op that reaches it refuses (`walkRefusal`), so each command // words the refusal as its own. Mirrors Python's PathSpec.walk_error. walkError; constructor(init) { this.virtual = init.virtual; this.directory = init.directory; this.vfsPath = init.vfsPath; this.pattern = init.pattern ?? null; this.resolved = init.resolved ?? true; this.rawPath = init.rawPath ?? init.virtual; this.dotted = init.dotted ?? null; this.walkError = init.walkError ?? null; Object.freeze(this); } // Mount-relative path with a leading slash. Pure formatting of // `vfsPath` ('' -> '/', 'sub/x' -> '/sub/x'); used for // byte-accounting keys and path arithmetic in slash-framed // mount-relative space. get mountPath() { return `/${this.vfsPath}`; } get dir() { // The directory's vfsPath is its virtual form with this path's // mount prefix removed; the prefix length is recovered from the // (virtual, vfsPath) pair. Idempotent for specs that are already // directories. const cut = rstripSlash(this.virtual).length - this.vfsPath.length; return new PathSpec({ virtual: this.directory, directory: this.directory, vfsPath: stripSlash(this.directory.slice(cut)), pattern: this.pattern, resolved: false, }); } child(name) { return `${rstripSlash(this.virtual)}/${name}`; } /** Join components for workspace dispatch, preserving the unproven walk. */ join(...parts) { let word = ''; for (const part of parts) word = part.startsWith('/') ? part : word ? `${word}/${part}` : part; const path = PathSpec.fromStrPath(word || '.', undefined, this); return new PathSpec({ virtual: path.virtual, directory: path.directory, vfsPath: path.vfsPath, rawPath: path.dotted ?? path.virtual, dotted: path.dotted, walkError: path.walkError, }); } /** Lexical parent with its spelled ancestors, for workspace dispatch. */ get parent() { const spelled = rstripSlash(this.dotted ?? this.virtual); return PathSpec.fromStrPath(spelled.slice(0, spelled.lastIndexOf('/')) || '/', undefined, '/'); } /** * Wrap a resolved path, or resolve a supplied spelling against cwd. * Existing specs retain their metadata; vfsPath can override the backend key. * Supply cwd for relative strings, dotted walks and inherited walk errors; * omit it for an already resolved virtual path. */ static fromStrPath(path, vfsPath, cwd) { if (path instanceof PathSpec) { return vfsPath === undefined ? path : new PathSpec({ virtual: path.virtual, directory: path.directory, vfsPath, rawPath: path.rawPath, pattern: path.pattern, resolved: path.resolved, dotted: path.dotted, walkError: path.walkError, }); } const base = cwd instanceof PathSpec ? (cwd.dotted ?? cwd.virtual) : cwd; const virtual = base === undefined ? path : resolvePath(path, base); let dotted = null; let walkError = null; if (base !== undefined) { dotted = cwd instanceof PathSpec && cwd.dotted !== null ? dottedSpelling(path.startsWith('/') ? path : `${base}/${path}`) : dottedSpelling(path, base); if (path === '') walkError = 'ENOENT'; else if (cwd instanceof PathSpec && !path.startsWith('/')) walkError = cwd.walkError; } return new PathSpec({ virtual, directory: virtual.slice(0, virtual.lastIndexOf('/') + 1) || '/', vfsPath: vfsPath ?? stripSlash(virtual), rawPath: path, dotted, walkError, }); } } // Shell-text form of an argv word. Text words pass through; paths render // as spelled (`rawPath`). Use wherever a word re-enters string space (env // values, function args, the argv text view). Mount I/O keeps using // `virtual`. export function wordText(word) { return word instanceof PathSpec ? word.rawPath : word; } //# sourceMappingURL=types.js.map