@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
606 lines • 29.3 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 { childMountNames, namespaceNames } from "../../view/namespace_view.js";
import { callerSignal, currentCaller, sessionAccess, sessionVisibility, } from "../../context/session_context.js";
import { YieldBudget } from "../../io/yield_budget.js";
import { walkBound } from "../../policy/builtin/output_cap.js";
import { mountKey } from "../../utils/key_prefix.js";
import { FileStat, FileType, PathSpec } from "../../types.js";
import { isFsError } from "../../errors/fs.js";
import { GlobLimitError } from "../../errors/types.js";
import { globNameMatches, globParts, globPattern, hasGlob as hasGlobChars, literalWord, makeResolveGlob, markGlobs, spellMatch, unmarkGlobs, } from "../../utils/glob_walk.js";
import { CycleError, parent } from "../../utils/path.js";
import { rstripSlash, stripSlash } from "../../utils/slash.js";
import { compareCodePoints } from "../../utils/sort.js";
import { DiscardSignal, ExitSignal, TooManyMatches } from "../../shell/errors.js";
import { SHOPT_DEFAULTS } from "../../shell/constants.js";
import { encodeText } from "../../shell/bytes.js";
// How deep a `**` descends. bash has no cap, but every level here is
// one listing per directory, so an accidental `**` over a large tree is
// bounded rather than open-ended.
const GLOBSTAR_MAX_DEPTH = 32;
/** Whether a mount command's glob must expand here rather than push
* down: the backend knows none of these. */
export function globNeedsShell(opts) {
return opts.nullglob || opts.failglob || opts.globstar || opts.extglob;
}
export function globOptions(session) {
return {
access: sessionAccess(session),
nullglob: session.shopts.nullglob ?? SHOPT_DEFAULTS.get('nullglob') ?? false,
failglob: session.shopts.failglob ?? SHOPT_DEFAULTS.get('failglob') ?? false,
globstar: session.shopts.globstar ?? SHOPT_DEFAULTS.get('globstar') ?? false,
extglob: session.shopts.extglob ?? SHOPT_DEFAULTS.get('extglob') ?? false,
};
}
// A directory's entries as the dispatcher lists them, the listing a glob
// walks: the session's hides, the links and the nested mount roots are the
// dispatcher's to apply. Mirrors Python's _listing.
async function listing(dispatch, path) {
const [entries] = await dispatch('readdir', path);
return [...entries];
}
// A path's row as the dispatcher answers it. Mirrors Python's _stat_of.
async function statOf(dispatch, path) {
const [row] = await dispatch('stat', path);
return row;
}
/**
* Expand pattern specs on one mount, one at a time, through the dispatcher's
* listing and stat, refused past `limit` matches (-1 for any). A mount that
* lists nothing, or no dispatcher (a registry no workspace wired one into),
* leaves every word as typed. Each word is keyed below the mount before the
* walk reads it. Mirrors Python's expand_glob.
*/
export async function expandGlob(dispatch, mount, paths, limit, access) {
if (dispatch === null || !mount.answers('readdir'))
return [...paths];
const prefix = rstripSlash(mount.prefix);
const resolve = makeResolveGlob((p) => listing(dispatch, p), limit, (p) => statOf(dispatch, p), undefined, access);
const out = [];
for (const p of paths) {
const spec = new PathSpec({
virtual: p.virtual,
directory: p.directory,
...(p.pattern !== null ? { pattern: p.pattern } : {}),
resolved: p.resolved,
vfsPath: mountKey(p.virtual, prefix),
rawPath: p.rawPath,
});
for (const item of await resolve([spec]))
out.push(item);
}
return out;
}
// Virtual paths a directory owes the namespace, matching a segment.
// Child mounts and symlinks are namespace state no backend can see, so a
// glob that stops at one backend misses both: a nested mount's keys live
// in another VFS, and no VFS stores a link. This is the union
// mergeReaddir already applies to a listing, filtered by the glob segment
// with the same matcher backends use, and filtered by the bound session's
// visibility, as the backend's own matches are, so a scoped session never
// learns an ungranted mount's name from an expansion.
function namespaceChildren(registry, links, directory, pattern, access) {
const base = rstripSlash(directory);
const matcher = globPattern(pattern);
return namespaceNames(sessionVisibility(access), registry.mountPrefixes(), links, directory)
.filter((name) => globNameMatches(name, matcher, access))
.map((name) => `${base}/${name}`);
}
// The mount owning a path, falling back to the word's own.
function mountOf(registry, virtual, fallback) {
return registry.tryMountFor(virtual) ?? fallback;
}
// The directory a backend must list to answer a glob's parent. bash
// descends through a symlinked directory during pathname expansion
// (`base/dlink/*` and `base/*/f2` both reach the target's entries), but a
// link is namespace state no backend can see, so the parent has to be
// resolved here or the listing comes back empty and the word stays
// literal. The match keeps the typed spelling, exactly as bash reports
// `base/dlink/f2` rather than the target's path.
function listingDir(links, directory) {
if (links === null)
return directory;
const base = rstripSlash(directory) || '/';
let real;
try {
real = links.follow(base);
}
catch (err) {
// A loop resolves to nothing, which is bash's own answer: the word
// matches no file and stays literal.
if (err instanceof CycleError)
return directory;
throw err;
}
return real === base ? directory : `${rstripSlash(real)}/`;
}
// Move matches found under a resolved directory back to the typed one.
function respell(virtuals, directory) {
const base = rstripSlash(directory);
return virtuals.map((v) => `${base}/${v.slice(v.lastIndexOf('/') + 1)}`);
}
// Key matched virtual paths to their mounts and spell them as typed.
function toSpecs(virtuals, item, registry, mount, walked, access) {
return virtuals.map((v) => {
const prefix = rstripSlash(mountOf(registry, v, mount).prefix);
const base = PathSpec.fromStrPath(v, mountKey(v, prefix));
return new PathSpec({
virtual: base.virtual,
directory: base.directory,
vfsPath: base.vfsPath,
rawPath: spellMatch(item.rawPath, v, walked, access),
});
});
}
// Union a backend's matches with the namespace-owed ones. Sorted, because
// bash sorts a pathname expansion and the two sources are enumerated
// separately. The backend is asked with a directory-shaped spec, which
// answers with matches alone, so "nothing matched" arrives as an empty list
// and the caller reinstates the literal only when the union is empty too.
//
// A match is a child of the directory it was globbed in, so a spec that is
// the directory itself is not one. The shared resolver never answers a
// dir-shaped ask that way, but `glob` is a public hook and a VFS
// reinstating the literal on its own would hand back the spec it was given.
// Unlike the word comparison this replaces, the test cannot discard a real
// match: a match is strictly longer than the directory holding it, while a
// word can be spelled exactly like one. `directory` arrives with its marks
// off, because a match is a real path a backend listed: a glob character
// quoted in the directory's name is a character of it, and the marked
// spelling would match no match at all.
function mergeNamespace(matches, extra, directory, registry, mount) {
const specs = matches.filter((m) => m.virtual.startsWith(directory) && m.virtual !== directory);
const seen = new Set(specs.map((m) => m.virtual));
for (const virtual of extra) {
if (seen.has(virtual))
continue;
seen.add(virtual);
// A nested mount root belongs to the mount it opens, not to the one
// being listed, so it is keyed against its own backend.
const owner = rstripSlash(mountOf(registry, virtual, mount).prefix);
specs.push(PathSpec.fromStrPath(virtual, mountKey(virtual, owner)));
}
return specs.sort((a, b) => compareCodePoints(a.virtual, b.virtual));
}
// One descent step: the dispatcher's listing of the parent's mount, matched,
// plus the namespace's. The walk can cross into a nested mount, because a
// mid-path segment may match a mount root, so the mount listed is the one
// owning that parent rather than the one owning the typed word. It can
// equally descend through a symlinked directory, so the parent is resolved
// through the namespace first and the matches are spelled back under the
// name that was typed.
async function levelMatches(registry, mount, links, dirVirtual, seg, access) {
const real = listingDir(links, dirVirtual);
const owner = mountOf(registry, real, mount);
await owner.ensureReady();
const prefix = rstripSlash(owner.prefix);
const out = [];
if (owner.answers('readdir')) {
const patternDir = prefix + markGlobs(real.slice(prefix.length));
const spec = new PathSpec({
virtual: patternDir,
directory: patternDir,
vfsPath: markGlobs(mountKey(real, prefix)),
pattern: seg,
resolved: false,
});
try {
// One step of a walk is not the word's expansion, which
// resolveGlobs limits once the walk is done.
const matches = await expandGlob(registry.dispatchFor(access), owner, [spec], -1, access);
// A descent step yields children, so a match that is the parent
// itself is not one. A backend asked to list a path that is really
// a file answers with that file, which walked back out as a
// doubled segment (`/base/f*/f1` -> `/base/base/f1`); bash keeps
// the literal because a file is not a directory to descend into.
const base = `${rstripSlash(real)}/`;
for (const m of matches) {
if (m.virtual.startsWith(base))
out.push(m.virtual);
}
}
catch (err) {
// fs-coded failures mean this parent is not a listable directory
// (bash skips it); anything else is a real bug and propagates. A
// nested mount root or a link under it is still real.
if (err.code === undefined)
throw err;
}
}
for (const item of namespaceChildren(registry, links, real, seg, access))
out.push(item);
return real === dirVirtual ? out : respell(out, dirVirtual);
}
// Stamp a glob match with the spelling the user's word implies.
// Bash expands `sub/*.txt` to relative matches (`sub/a.txt`), keeping
// the typed prefix. The glob item's rawPath records the word as typed;
// matches rebuild it by swapping the resolved directory prefix for the
// typed one. Words with no distinct spelling (absolute: rawPath ===
// virtual) keep the resolved virtual, as do matches that already carry
// one.
function matchRaw(item, match) {
if (item.rawPath === item.virtual || match.rawPath !== match.virtual)
return match;
// A mark is one character wide, so the directory's marked and literal
// spellings are the same length and this cut holds either way; only the
// head that is carried over has to lose its marks.
if (!match.virtual.startsWith(unmarkGlobs(item.directory)))
return match;
const rawDir = unmarkGlobs(item.rawPath.slice(0, item.rawPath.lastIndexOf('/') + 1));
const spelled = rawDir + match.virtual.slice(item.directory.length);
return new PathSpec({
virtual: match.virtual,
directory: match.directory,
pattern: match.pattern,
resolved: match.resolved,
vfsPath: match.vfsPath,
rawPath: spelled,
});
}
function joinSpelling(head, name) {
if (head === '')
return name;
return `${rstripSlash(head)}/${name}`;
}
async function descend(registry, mount, links, parent, spelled, depth, access) {
if (depth >= GLOBSTAR_MAX_DEPTH)
return [];
const out = [];
const children = [
...new Set(await levelMatches(registry, mount, links, `${parent}/`, '*', access)),
].sort(compareCodePoints);
for (const child of children) {
const childSpelled = joinSpelling(spelled, child.split('/').pop() ?? '');
out.push([child, childSpelled]);
for (const item of await descend(registry, mount, links, child, childSpelled, depth + 1, access))
out.push(item);
}
return out;
}
// Expand a word level by level, one segment at a time. A glob in a
// non-final segment (`s*/x.txt`) cannot resolve in one listing, so each
// segment is matched against its (already expanded) parents with the owning
// backend's own single-level glob, and an intermediate match that cannot be
// listed is skipped, as in bash's directories-only descent. The walk starts
// at the first glob or dot segment: a `.` or `..` applies to each parent
// that is a directory, `..` climbing from where a link leads, which is the
// kernel's walk of `name/..` that bash's opendir makes, so a missing or
// plain-file name in front of one matches nothing. Under `globstar` a `**` segment matches
// zero or more directory levels: the parent itself (spelled with a
// trailing slash when the word has a fixed head, `d/**` -> `d/`, and left
// out for a bare `**`) plus every descendant. The spelling is carried level
// by level, the typed head plus each segment as matched. Mirrors Python's
// _walk.
async function walk(item, mount, registry, links, globstar, access) {
const typed = globParts(stripSlash(item.dotted ?? item.virtual), access);
const first = typed.findIndex((seg) => hasGlobChars(seg, access) || seg === '.' || seg === '..');
const raw = globParts(rstripSlash(item.rawPath), access).map(unmarkGlobs);
let spelledHead = raw.slice(0, raw.length - (typed.length - first)).join('/');
if (item.rawPath.startsWith('/') && spelledHead === '')
spelledHead = '/';
// The head above the first glob or dot segment is a real directory, so a
// glob character quoted inside it is part of the name to list.
const head = unmarkGlobs('/' + typed.slice(0, first).join('/'));
let level = [[head, spelledHead, false]];
for (const seg of typed.slice(first)) {
const gathered = [];
for (const [dir, spelled] of level) {
if (seg === '.' || seg === '..') {
if (await isDirectory(registry, mount, links, dir, access)) {
const real = links !== null ? links.follow(dir) : dir;
gathered.push([seg === '..' ? parent(real) : dir, joinSpelling(spelled, seg), false]);
}
}
else if (globstar && seg === '**') {
gathered.push([dir, spelled, true]);
for (const [v, sp] of await descend(registry, mount, links, dir, spelled, 0, access)) {
gathered.push([v, sp, false]);
}
}
else {
for (const child of await levelMatches(registry, mount, links, `${rstripSlash(dir)}/`, seg, access)) {
gathered.push([child, joinSpelling(spelled, child.split('/').pop() ?? ''), false]);
}
}
}
// bash sorts a pathname expansion, and the backend and the namespace
// are enumerated separately, so the union is ordered here, one entry
// per spelling.
const seen = new Map();
for (const entry of gathered)
if (!seen.has(entry[1]))
seen.set(entry[1], entry);
level = [...seen.values()].sort((a, b) => compareCodePoints(a[1], b[1]));
if (level.length === 0)
return [];
}
return level
.filter(([, sp, isSelf]) => sp !== '' || !isSelf)
.map(([v, sp, isSelf]) => {
const base = PathSpec.fromStrPath(v, mountKey(v, rstripSlash(mountOf(registry, v, mount).prefix)));
return new PathSpec({
virtual: base.virtual,
directory: base.directory,
vfsPath: base.vfsPath,
rawPath: isSelf ? `${rstripSlash(sp)}/` : sp,
});
});
}
// Whether a match is a directory, the way a trailing slash asks. bash keeps
// a directory or a symlink to one and drops a regular file or a broken link
// (bash 5.2, `*/`). A nested mount root is a directory by construction;
// anything else is the dispatcher's stat of the link-resolved path, one per
// match. The mount it lands on is readied first, as levelMatches readies one
// before listing it, because a link can point into a mount nothing has
// touched yet. An unclassified match is dropped.
async function isDirectory(registry, mount, links, virtual, access) {
let real = virtual;
if (links !== null) {
try {
real = links.follow(virtual);
}
catch (err) {
if (err instanceof CycleError)
return false;
throw err;
}
}
const owner = mountOf(registry, real, mount);
const prefix = rstripSlash(owner.prefix);
if (rstripSlash(real) === prefix)
return true;
const dispatch = registry.dispatchFor(access);
if (dispatch === null)
return false;
let row;
try {
await owner.ensureReady();
row = await statOf(dispatch, PathSpec.fromStrPath(real));
}
catch (err) {
if (isFsError(err))
return false;
throw err;
}
return row instanceof FileStat && row.type === FileType.DIRECTORY;
}
function withTrailingSlash(spec) {
return new PathSpec({
virtual: spec.virtual,
directory: spec.directory,
pattern: spec.pattern,
resolved: spec.resolved,
vfsPath: spec.vfsPath,
rawPath: `${spec.rawPath}/`,
});
}
function hasGlobstarSegment(item, access) {
return globParts(item.virtual, access).includes('**');
}
export async function resolveGlobs(classified, registry, noglob = false, links = null, options = null) {
// set -f: skip resolution entirely, so every glob word keeps its
// literal spelling like a zero-match glob.
if (noglob)
return classified.map((item) => literalWord(item));
const opts = options ?? {
nullglob: false,
failglob: false,
globstar: false,
extglob: false,
};
const access = opts.access;
const result = [];
const budget = new YieldBudget(callerSignal());
for (const item of classified) {
await budget.run();
if (item instanceof PathSpec && item.pattern !== null) {
// A pattern word no mount owns stays the literal word like a
// zero-match glob.
const mount = registry.tryMountFor(unmarkGlobs(item.virtual));
if (mount === null) {
result.push(item);
continue;
}
const prefix = rstripSlash(mount.prefix);
// A VFS that lists nothing can still hold a nested mount
// root or a link under the globbed directory; with nothing for the
// namespace to add it keeps the untouched pass-through it had.
const midPath = item.dotted !== null || hasGlobChars(item.directory, access);
// The parent directory is a real directory to list, so a glob
// character quoted inside it is part of its name.
const directory = unmarkGlobs(item.directory);
// The parent is a symlink, so the backend holding the typed path
// has nothing to list and levelMatches has to follow it first.
const linked = !midPath && listingDir(links, directory) !== directory;
const extra = midPath || linked ? [] : namespaceChildren(registry, links, directory, item.pattern, access);
if (!linked && !mount.answers('readdir') && extra.length === 0) {
result.push(item);
continue;
}
// A trailing slash asks for directories only, and every match keeps
// one (`*/` -> `sub/`, and so does `*//`). The slash is not part of
// the spelling to rebuild, so it comes off the word here and goes
// back on each match; the literal answer to a zero-match glob is
// still the word as typed. normpath already dropped it from
// `virtual`, which is what tells a typed word from a
// directory-shaped spec (#1065).
const dirsOnly = item.rawPath.endsWith('/') && item.rawPath !== item.virtual;
const withPrefix = new PathSpec({
virtual: item.virtual,
directory: item.directory,
pattern: item.pattern,
resolved: item.resolved,
vfsPath: mountKey(item.virtual, prefix),
rawPath: dirsOnly ? rstripSlash(item.rawPath) : item.rawPath,
dotted: item.dotted,
});
const typed = dirsOnly
? new PathSpec({
virtual: withPrefix.virtual,
directory: withPrefix.directory,
pattern: withPrefix.pattern,
resolved: withPrefix.resolved,
vfsPath: withPrefix.vfsPath,
rawPath: item.rawPath,
})
: withPrefix;
await mount.ensureReady();
try {
let resolved;
if (midPath || (opts.globstar && hasGlobstarSegment(withPrefix, access))) {
resolved = await walk(withPrefix, mount, registry, links, opts.globstar, access);
}
else if (linked) {
const found = await levelMatches(registry, mount, links, directory, item.pattern, access);
resolved = toSpecs([...new Set(found)].sort(compareCodePoints), withPrefix, registry, mount, 1, access);
}
else {
// Asked with the word, a backend that matched nothing answers
// with the word (nullglob off), which is byte-identical to a
// real match on a file named like the pattern -- `*a.txt` next
// to `xa.txt` lost its first match to that ambiguity. The
// directory-shaped spec has no literal to reinstate, so an
// empty list means no match and every spec returned is one.
const own = await expandGlob(registry.dispatchFor(access), mount, [withPrefix.dir], -1, access);
resolved = mergeNamespace(own, extra, directory, registry, mount);
}
if (dirsOnly) {
const kept = [];
for (const p of resolved) {
if (await isDirectory(registry, mount, links, p.virtual, access))
kept.push(p);
}
resolved = kept;
}
// Each match counts against the mount serving it, as a word can
// reach into a mount below its own or follow a link into another,
// and against the walkLimit every command shares there: a word
// expands before its command runs.
const counts = new Map();
const bounds = new Map();
const profile = currentCaller().session?.commandLimits ?? {};
for (const match of resolved) {
const served = links !== null ? links.followParent(match.virtual) : match.virtual;
const owner = mountOf(registry, served, mount);
const count = (counts.get(owner.prefix) ?? 0) + 1;
counts.set(owner.prefix, count);
let limit = bounds.get(owner.prefix);
if (limit === undefined) {
limit = walkBound(owner, null, null, registry.commandLimits, profile);
bounds.set(owner.prefix, limit);
}
if (limit >= 0 && count > limit) {
throw new TooManyMatches(new GlobLimitError(unmarkGlobs(typed.rawPath), limit).message);
}
}
if (resolved.length === 0) {
// bash's three answers to a zero-match glob: the literal word
// (default), nothing at all under nullglob, and a fatal
// expansion error under failglob. The literal is resolved, or the
// command's backend would glob it again over the simplified path
// (`missing/../*` as `*`); the pattern stays, so a push-down still
// reads it as no entity name.
if (opts.failglob) {
const word = unmarkGlobs(typed.rawPath);
throw new DiscardSignal(encodeText(`bash: no match: ${word}\n`));
}
if (!opts.nullglob) {
result.push(new PathSpec({
virtual: item.virtual,
directory: item.directory,
vfsPath: item.vfsPath,
rawPath: item.rawPath,
dotted: item.dotted,
walkError: item.walkError,
pattern: item.pattern,
resolved: true,
}));
}
}
else {
for (const p of resolved) {
const spelled = matchRaw(withPrefix, p);
result.push(dirsOnly ? withTrailingSlash(spelled) : spelled);
}
}
}
catch (err) {
// A failglob refusal is fatal and propagates, as does a glob past
// the mount's limit; an ordinary resolution failure keeps the
// literal word.
if (err instanceof ExitSignal)
throw err;
result.push(typed);
}
}
else {
result.push(item);
}
}
// Resolution is over, so the quoting the marks carried has done its
// work: what leaves is the word after quote removal, matched or not.
return result.map((item) => literalWord(item));
}
// The fixed directory above a word's first glob segment.
function globHead(spec, access) {
const fixed = [];
for (const seg of globParts(spec.virtual, access)) {
if (hasGlobChars(seg, access))
break;
fixed.push(seg);
}
return fixed.join('/') + '/';
}
/**
* Expand glob words that could match across a mount boundary.
*
* A glob operand is normally left for the owning backend to resolve,
* which is how a prefix store pushes the listing down. That only holds
* while every match belongs to that backend: a nested mount's root is a
* child of the directory but its keys live in another VFS, so the
* backend answers "no such file" for a name its own listing shows. When
* the glob's fixed head holds a child mount, the word is expanded here
* instead, before routing, so the matches route per mount exactly as the
* same paths typed by hand already do. Every other glob is left
* untouched, so pushdown is unaffected, and so is a word the shell already
* resolved: a glob that matched nothing stays as typed.
*/
export async function expandBoundaryGlobs(parts, registry, links, access) {
const prefixes = registry.mountPrefixes();
const vis = sessionVisibility(access);
const spans = (p) => p instanceof PathSpec &&
p.pattern !== null &&
!p.resolved &&
childMountNames(vis, prefixes, globHead(p, access)).length > 0;
if (!parts.some(spans))
return [...parts];
const out = [];
for (const item of parts) {
if (spans(item)) {
out.push(...(await resolveGlobs([item], registry, false, links, {
nullglob: false,
failglob: false,
globstar: false,
extglob: access?.shopts.extglob === true,
...(access === undefined ? {} : { access }),
})));
}
else {
out.push(item);
}
}
return out;
}
//# sourceMappingURL=globs.js.map