@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
552 lines • 23 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 { 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