UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

552 lines • 23 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 { FileStat, FileType, LINK_TARGET_KEY, } from "../../../types.js"; import { decodeBase64, encodeBase64 } from "../../../utils/base64.js"; import { epochToIso } from "../../../utils/dates.js"; import { ancestors, globPrefixMatch, resolveSymlinks } from "../../../utils/path.js"; import { rstripSlash } from "../../../utils/slash.js"; import { RAMNamespaceStore } from "./ram.js"; import { boundSystemClock } from "../../../utils/clock.js"; import { NamespaceReader } from "../../dispatcher/reader.js"; import { encodeText } from "../../../shell/bytes.js"; import { getCurrentSession } from "../../../context/session_context.js"; // An extended attribute rides a node's flat field set as one field per // name, `xattr:<name>`, its value base64 so every store (a JSON file, a // Redis hash, a snapshot) holds the bytes as a string. const XATTR_FIELD_PREFIX = 'xattr:'; // Render a symlink node as a stat row. Size is the target string's // byte length and mode is left unset so the formatter supplies 0777, // which is what a real symlink inode reports (a link carries no // permission bits of its own). Ownership and the stamps are not in that // category: a link has a real uid/gid that `chown -h` writes and `ls -l` // shows, and real times that `touch -h` writes, so all four ride // through from the node. The target rides along in `extra` so // every surface that has to name it (ls -l's `name -> target`, file's // "symbolic link to") reads one fact rather than querying the link // table a second time. function linkStat(name, meta) { const target = meta.target ?? ''; return new FileStat({ name, size: encodeText(target).length, modified: meta.mtime !== undefined ? epochToIso(meta.mtime) : null, type: FileType.SYMLINK, ...(meta.uid !== undefined ? { uid: meta.uid } : {}), ...(meta.gid !== undefined ? { gid: meta.gid } : {}), ...(meta.atime !== undefined ? { atime: meta.atime } : {}), extra: { [LINK_TARGET_KEY]: target }, }); } export function metaToFields(meta) { const out = {}; if (meta.target !== undefined) out.target = meta.target; if (meta.mtime !== undefined) out.mtime = meta.mtime; if (meta.mode !== undefined) out.mode = meta.mode; if (meta.uid !== undefined) out.uid = meta.uid; if (meta.gid !== undefined) out.gid = meta.gid; if (meta.atime !== undefined) out.atime = meta.atime; if (meta.observedMtime !== undefined) out.observed_mtime = meta.observedMtime; for (const [name, value] of meta.xattrs ?? []) { out[XATTR_FIELD_PREFIX + name] = encodeBase64(value); } return out; } export function metaFromFields(fields) { const meta = {}; if (typeof fields.target === 'string') meta.target = fields.target; if (typeof fields.mtime === 'number') meta.mtime = fields.mtime; if (typeof fields.mode === 'number') meta.mode = fields.mode; if (typeof fields.uid === 'number' || typeof fields.uid === 'string') meta.uid = fields.uid; if (typeof fields.gid === 'number' || typeof fields.gid === 'string') meta.gid = fields.gid; if (typeof fields.atime === 'string') meta.atime = fields.atime; if (typeof fields.observed_mtime === 'number') meta.observedMtime = fields.observed_mtime; const xattrs = new Map(); for (const [key, value] of Object.entries(fields)) { if (key.startsWith(XATTR_FIELD_PREFIX) && typeof value === 'string') { xattrs.set(key.slice(XATTR_FIELD_PREFIX.length), decodeBase64(value)); } } if (xattrs.size > 0) meta.xattrs = xattrs; return meta; } // Addressing authority: maps virtual paths to their mounts. Owns the mount // registry and the per-path node-metadata table (symlinks plus the attribute // overlay). Pure addressing: resolve a virtual path to its VFS and // backend-relative path, following symlinks and crossing mounts. Holds no // cache and performs no backend I/O; op execution and caching live in the // Dispatcher, which calls this layer to locate the mount. // // The in-memory table is the working copy (reads stay synchronous on the // hot path); every mutation writes through to a NamespaceStore (RAM by // default, Redis for restart-durable namespaces), and the table hydrates // from the store once on first use. export class Namespace { registry; resolveFn; store; nodeTable = new Map(); // Every link and its target, kept until the table next changes. links = null; loaded = false; loading = null; constructor(registry, resolveFn, store) { this.registry = registry; this.resolveFn = resolveFn; this.store = store ?? new RAMNamespaceStore(); } get nodes() { return this.nodeTable; } /** The workspace clock, which the dispatcher records a call outside a line on. */ get clock() { return this.registry.clock; } // Hydrate the working copy from the store, once. A snapshot restore // (`replaceNodes`) marks the table loaded, so snapshot state wins over // whatever the store held before it. async ensureLoaded() { if (this.loaded) return; this.loading ??= this.store.load().then((entries) => { if (this.loaded) return; const table = new Map(); for (const [path, fields] of entries) table.set(path, metaFromFields(fields)); this.nodeTable = table; this.links = null; this.loaded = true; }); await this.loading; } async replaceNodes(entries) { this.nodeTable = new Map(entries); this.links = null; this.loaded = true; const out = new Map(); for (const [path, meta] of entries) out.set(path, metaToFields(meta)); await this.store.replaceAll(out); } async close() { await this.store.close(); } // Every link and its target. Every op follows through this map, so it // is built once per change rather than once per follow. symlinkTargets() { if (this.links === null) { const out = new Map(); for (const [path, meta] of this.nodeTable) { if (meta.target !== undefined) out.set(path, meta.target); } this.links = out; } return this.links; } isLink(path) { return this.nodeTable.get(path)?.target !== undefined; } readlink(path) { return this.nodeTable.get(path)?.target ?? null; } async symlink(link, target, mtime) { const meta = this.nodeTable.get(link) ?? {}; meta.target = target; meta.mtime = mtime; this.nodeTable.set(link, meta); this.links = null; await this.store.set(link, metaToFields(meta)); } metaFor(path) { return this.nodeTable.get(path) ?? null; } // Write overlay attributes for a path (setattr fallback). Used for // symlinks (which have no backend inode) and for backends without a // native metadata slot. Only present fields are written. async setAttrs(path, fields) { const meta = this.nodeTable.get(path) ?? {}; if (fields.mode !== undefined) meta.mode = fields.mode; if (fields.uid !== undefined) meta.uid = fields.uid; if (fields.gid !== undefined) meta.gid = fields.gid; if (fields.atime !== undefined) meta.atime = fields.atime; if (fields.mtime !== undefined) meta.mtime = fields.mtime; this.nodeTable.set(path, meta); await this.store.set(path, metaToFields(meta)); } // The extended attributes a caller set on a path, by name. xattrs(path) { return new Map(this.nodeTable.get(path)?.xattrs ?? []); } // Store one extended attribute on a path's node. async setXattr(path, name, value) { const meta = this.nodeTable.get(path) ?? {}; meta.xattrs ??= new Map(); meta.xattrs.set(name, Uint8Array.from(value)); this.nodeTable.set(path, meta); await this.store.set(path, metaToFields(meta)); } // Drop one extended attribute, and the node once it holds nothing. async removeXattr(path, name) { const meta = this.nodeTable.get(path); if (meta?.xattrs?.delete(name) !== true) return; if (meta.xattrs.size === 0) delete meta.xattrs; if (Object.keys(meta).length === 0) { this.nodeTable.delete(path); await this.store.delete([path]); return; } await this.store.set(path, metaToFields(meta)); } // Drop overlay fields that a backend has applied natively. A // residual-free native setattr means the real inode now holds the // requested value, so a stale overlay field would shadow it forever // (chmod 000 then chmod 644 must not keep showing 000). Symlink // entries keep their target. async dropAttrs(path, fields) { const meta = this.nodeTable.get(path); if (meta === undefined) return; for (const field of fields) { if (field !== 'target') { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete meta[field]; } } if (Object.keys(meta).length === 0) { this.nodeTable.delete(path); await this.store.delete([path]); return; } await this.store.set(path, metaToFields(meta)); } // Drop a metadata overlay whose backend file is gone (orphan GC). When a // backend reports a path deleted, its attribute overlay is orphaned and // would otherwise linger forever. Symlink entries are authoritative (they // have no backend file), so they are left intact; only a target-less // overlay node is removed. async dropOverlay(path) { const meta = this.nodeTable.get(path); if (meta === undefined || meta.target !== undefined) return false; this.nodeTable.delete(path); await this.store.delete([path]); return true; } async dropOverlaysUnder(paths, excluded = []) { const roots = new Set(paths.map((path) => path.replace(/\/+$/, '') || '/')); const protectedPaths = new Set(excluded.map((path) => path.replace(/\/+$/, '') || '/')); const doomed = []; for (const [key, meta] of this.nodeTable) { if (meta.target !== undefined) continue; const lineage = ['/', ...ancestors(key), key.replace(/\/+$/, '') || '/']; if (lineage.some((path) => roots.has(path)) && !lineage.some((path) => protectedPaths.has(path))) { doomed.push(key); } } for (const key of doomed) this.nodeTable.delete(key); if (doomed.length > 0) await this.store.delete(doomed); return doomed.length; } // Drop overlay times after a content write. write(2) refreshes mtime, // so a stored overlay time would otherwise shadow the backend's fresh // one forever. Permission and ownership survive writes; a symlink entry // keeps its own times. Under a system_time the write's time is the // file's mtime, over the backend's, which the host's clock stamped. async clearTimes(path, observed = null) { let meta = this.nodeTable.get(path); if (meta === undefined && observed !== null) { meta = {}; this.nodeTable.set(path, meta); } if (meta === undefined || meta.target !== undefined) return; if (observed !== null && boundSystemClock() !== null) meta.mtime = observed; else delete meta.mtime; delete meta.atime; // null (a removal) also drops any prior observed time, so a // deleted path's meta does not linger. if (observed === null) delete meta.observedMtime; else meta.observedMtime = observed; if (Object.keys(meta).length === 0) { this.nodeTable.delete(path); await this.store.delete([path]); return; } await this.store.set(path, metaToFields(meta)); } async unlink(path) { const removed = this.nodeTable.delete(path); if (removed) { this.links = null; await this.store.delete([path]); } return removed; } // Drop node entries matching an unexpanded glob operand. `rm` receives // the pattern verbatim (backend wrappers expand globs themselves), so // the node table must match it here. Drops matched entries and // everything under a matched directory, save the entries in `keep`. async unlinkGlob(pattern, keep = new Set()) { const doomed = []; for (const path of this.nodeTable.keys()) { if (globPrefixMatch(path, pattern) && !keep.has(path)) doomed.push(path); } for (const path of doomed) this.nodeTable.delete(path); if (doomed.length > 0) { this.links = null; await this.store.delete(doomed); } return doomed.length; } async rename(src, dst) { const meta = this.nodeTable.get(src); if (meta === undefined) return false; this.nodeTable.delete(src); this.nodeTable.set(dst, meta); this.links = null; await this.store.set(dst, metaToFields(meta)); await this.store.delete([src]); return true; } // Return `path` with all symlink prefixes resolved; identity when the // table is empty or nothing matches. Throws CycleError on ELOOP. follow(path) { const targets = this.symlinkTargets(); if (targets.size === 0) return path; return resolveSymlinks(path, targets); } // Return `path` with every link above its final name resolved: the walk // the kernel gives a path before the call sees it, where the last // component is the op's own to follow or not (stat against lstat). // Identity when no link sits above the name, a trailing slash included. // Throws CycleError on ELOOP. Mirrors Python's Namespace.follow_parent. followParent(path) { const trimmed = rstripSlash(path); const cut = trimmed.lastIndexOf('/'); const name = trimmed.slice(cut + 1); if (cut < 0 || name === '') return path; const parent = trimmed.slice(0, cut); const above = parent === '' ? '/' : parent; const resolved = this.follow(above); if (resolved === above) return path; return rstripSlash(resolved) + path.slice(parent.length); } // lstat a path: the link's own stat, or null when not a link. A // symlink has no backend inode, so the node table is the only // authority for it. linkStatAt(path) { const key = rstripSlash(path) || path; const meta = this.nodeTable.get(key); if (meta?.target === undefined) return null; return linkStat(key.split('/').pop() ?? key, meta); } // Every link at any depth under a directory, with its own stat. A // walker (find, du) needs the whole subtree, unlike a listing (ls) // which wants one level. linkStatsBelow(directory) { const base = rstripSlash(directory) + '/'; const out = []; for (const path of this.symlinkTargets().keys()) { const meta = this.nodeTable.get(path); if (meta !== undefined && path.startsWith(base)) { out.push([path, linkStat(path.split('/').pop() ?? path, meta)]); } } return out; } // The names of the links living directly under a directory. What a // readdir row's link mark needs, which is a name question rather than // a stat one: the dispatcher already holds every entry's stat and has only // to learn which of those names the node table owns. // // Resolves a link prefix first, because a listing does: a readdir of // `/data/alias` is dispatched at `/data/real` and answers with that // directory's entries, so the marks have to come from there too or // every link inside an aliased directory reads as whatever its // followed stat said. linkNamesUnder(directory) { return new Set(this.linksUnder(this.follow(directory)).map(([name]) => name)); } // The links living directly under a directory, as (name, meta). linksUnder(directory) { const base = rstripSlash(directory) + '/'; const out = []; for (const path of this.symlinkTargets().keys()) { const meta = this.nodeTable.get(path); if (meta !== undefined && path.startsWith(base) && !path.slice(base.length).includes('/')) { out.push([path.slice(base.length), meta]); } } return out; } /** * Re-anchor every node below one directory onto another. * * A rename moves a whole subtree, and the node table addresses its entries by * absolute path, so a link or an attr overlay below the source names a path * that no longer exists once the backend has moved the bytes. No backend can * report those entries, which is why nothing below the dispatcher can do this. * Entries in a mount below stay, as purgeUnder keeps them: the mount stays * where it is, so its links and overlays are still its own, whenever it was * mounted. Mirrors Python's `rename_under`. */ async renameUnder(src, dst) { const base = rstripSlash(src) + '/'; const roots = this.registry.descendantMounts(src).map((m) => rstripSlash(m.prefix)); const moved = []; for (const [path, meta] of this.nodeTable) { if (!path.startsWith(base)) continue; if (roots.some((r) => path === r || path.startsWith(r + '/'))) continue; moved.push([path, meta]); } if (moved.length === 0) return 0; const landing = rstripSlash(dst); const landed = moved.map(([path, meta]) => [ `${landing}/${path.slice(base.length)}`, meta, ]); // The table moves whole before the store is awaited: a read in between // rebuilds the link map, which nothing clears again. for (const [path] of moved) this.nodeTable.delete(path); for (const [path, meta] of landed) this.nodeTable.set(path, meta); this.links = null; for (const [path, meta] of landed) await this.store.set(path, metaToFields(meta)); await this.store.delete(moved.map(([path]) => path)); return moved.length; } // Drop every node entry under a directory (`rm -r` semantics), except // the entries in `keep`. Entries in a mount below stay: removing a tree // never reaches into another mount, so its links and overlays are still // that mount's. async purgeUnder(directory, keep = new Set()) { const base = rstripSlash(directory) + '/'; const roots = this.registry.descendantMounts(directory).map((m) => rstripSlash(m.prefix)); const doomed = []; for (const path of this.nodeTable.keys()) { if (!path.startsWith(base) || keep.has(path)) continue; if (roots.some((r) => path === r || path.startsWith(r + '/'))) continue; doomed.push(path); } for (const path of doomed) this.nodeTable.delete(path); if (doomed.length > 0) { this.links = null; await this.store.delete(doomed); } return doomed.length; } // Map a virtual path to its mount, following the symlink table first when // `follow` is set. Throws CycleError when resolution exceeds the hop limit. async resolve(path, follow = true) { if (follow) path = this.follow(path); return this.resolveFn(path); } mountFor(path) { return this.registry.mountFor(path); } tryMountFor(path) { return this.registry.tryMountFor(path); } mountPrefixes() { return this.registry.mountPrefixes(); } /** The workspace's command limits, the layer under each mount's. */ get commandLimits() { return this.registry.commandLimits; } /** The workspace's max_memory, null for no cap. */ get memory() { return this.registry.memory; } /** The rule the workspace's connections are judged by, null for no limit. */ get network() { return this.registry.network; } /** The workspace's system clock, null for the host's time. */ get systemClock() { return this.registry.systemClock; } /** * How a session's files read this namespace: the reader for whichever * session an op runs as (`NamespaceReader`), over the link table and the * mount registry this owns, read live. Mirrors Python's `Namespace.readers`. * * @param visibility the visibility of a session, by id; null names the * session bound when the question is asked. */ readers(visibility) { return (sessionId) => new NamespaceReader(this, () => this.registry.mountRows(), sessionId, () => visibility(sessionId)); } /** * The link table as one session sees it (`NamespaceReader`), the one every * reader above the dispatcher follows a link through: a link the session * cannot see is no link to any command, so none resolves, copies or reports * a hidden one. Mirrors Python's `Namespace.session_links`. * * @param session the session; omitted for the one the current context * runs as. */ sessionLinks(session) { const bound = session ?? getCurrentSession(); return this.readers(() => (bound === null ? null : bound.visibility))(bound === null ? null : bound.sessionId); } } //# sourceMappingURL=namespace.js.map