@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
417 lines • 19.7 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 { EXEC_PLACEHOLDER } from "../../commands/builtin/constants.js";
import { expandPrintf } from "../../commands/builtin/find_printf.js";
import { formatFindLs } from "../../commands/builtin/utils/formatting.js";
import { linkView } from "../../commands/builtin/utils/links.js";
import { getCurrentSession, runAsProgram } from "../../context/session_context.js";
import { modifiedTs } from "../../core/generic/find.js";
import { printfKind } from "../../core/generic/find_eval.js";
import { enoent, strerrorOf } from "../../errors/fs.js";
import { posixPhrase } from "../../errors/posix.js";
import { SharedStdin } from "../../io/stream.js";
import { materialize } from "../../io/types.js";
import { PolicyDenied } from "../../policy/errors.js";
import { encodeText } from "../../shell/bytes.js";
import { shellJoin } from "../../shell/join.js";
import { FileType } from "../../types.js";
import { concat } from "../../utils/bytes.js";
import { resolvePath } from "../../utils/path.js";
import { rstripSlash } from "../../utils/slash.js";
import { compareCodePoints } from "../../utils/sort.js";
import { contentSize } from "../../utils/stat_view.js";
import { SHELL_ONLY_BUILTINS } from "../lookup/constants.js";
import { lookupAll } from "../lookup/lookup.js";
import { Consumer } from "../lookup/types.js";
/**
* The shell line one `-exec` run becomes. GNU execs the words directly, so
* every match must reach the command as exactly one argv word: the line is
* built with `shellJoin`, and a plain join would be re-parsed by the shell.
* A per-match run substitutes every `{}` inside every word (`x{}y` is
* `xd/a.txty`); a batched run replaces its one bare `{}` with the matches,
* one word each.
*/
/**
* The argv one `-exec` run becomes, matches substituted: a per-match run
* substitutes every `{}` inside every word (`x{}y` is `xd/a.txty`), a
* batched run replaces its one bare `{}` with the matches, one word
* each. The head is substituted like any other word, which is what lets
* `-exec {} \;` run each match itself.
*/
export function execWords(action, paths) {
const words = [];
for (const word of action.argv) {
if (action.batch && word === EXEC_PLACEHOLDER)
for (const item of paths)
words.push(item);
else if (!action.batch)
words.push(word.replaceAll(EXEC_PLACEHOLDER, paths[0] ?? ''));
else
words.push(word);
}
return words;
}
/**
* Whether `execvp` would fail to find a head word (`find -exec`, `exec`),
* and whether a shell function shadows the program it would find. A head
* carrying a slash is a file the loader runs, which no builtin, function or
* CLI can claim, so it is statted where the line would read it; any other
* head is looked up by name across the layers dispatch consults, in
* `session`'s shell. Outside a workspace there is no stat and the loader
* answers for itself.
*/
export async function programHead(head, session, registry, cwd, statPath) {
if (head.includes('/')) {
return [statPath !== null && (await statPath(resolvePath(head, cwd))) === null, false];
}
const sess = session;
// A shell function is not found either, nor a builtin that is the
// shell's own: GNU execs the head through execvp, which sees programs
// and nothing the shell defined, so `f(){ :; }; find d -exec f {} \;`
// and `find d -exec cd {} \;` report `No such file or directory` per
// match while `-exec echo` or `-exec sh -c` runs (SHELL_ONLY_BUILTINS
// names the shell's own). Every layer is asked, not the winner: execvp
// never sees the function `cat(){ ...; }` defines, so `-exec cat` still
// finds the program, and the run bypasses the function the way
// `command` does.
if (sess === null)
return [false, false];
const layers = lookupAll(head, sess, registry);
const program = layers.some((layer) => layer !== Consumer.FUNCTION && (layer !== Consumer.SESSION || !SHELL_ONLY_BUILTINS.has(head)));
// A function is invisible to execvp, and `command` masks it for the run;
// no alias rewrites a program line.
const shadowed = layers.includes(Consumer.FUNCTION);
return [!program, shadowed];
}
async function runExec(executeFn, session, registry, cwd, statPath, action, paths, out, errors, stdin) {
// GNU substitutes the matches into the words and only then hands them
// to execvp, so the head looked up is the substituted one: `-exec {}
// \;` runs each match itself.
const words = execWords(action, paths);
const head = words[0] ?? action.argv[0] ?? '';
const [missing, shadowed] = await programHead(head, session, registry, cwd, statPath);
if (missing) {
errors.push(encodeText(`find: '${head}': No such file or directory\n`));
return false;
}
// A function or alias of the head's name is invisible to execvp, so the
// line runs the program past it, as `command` does. The run is marked a
// program run for the session, so a builtin that doubles as a program
// answers as the program (`printf -v` is a format).
const line = (shadowed ? 'command ' : '') + shellJoin(words);
const run = () => executeFn(`( ${line} )`, { sessionId: session?.sessionId ?? '', stdin });
const io = session === null ? await run() : await runAsProgram(session, run);
if (io.stdout !== null) {
const data = await materialize(io.stdout);
if (data.byteLength > 0)
out.push(data);
}
const err = await materialize(io.stderr);
if (err.byteLength > 0)
errors.push(err);
return io.exitCode === 0;
}
/** Remove a matched entry through the dispatcher, which owns admission,
* backend support, cache invalidation and namespace cleanup. */
async function deleteRow(ps, vfs, dispatch, errors, statPath) {
const path = ps.rawPath || ps.virtual;
if (dispatch === null) {
errors.push(encodeText('find: -delete requires an operation dispatcher\n'));
return false;
}
try {
const link = (linkView(vfs ?? undefined)?.statAt(ps.virtual) ?? null) !== null;
const st = link || statPath === null ? null : await statPath(ps);
if (!link && statPath !== null && st === null)
throw enoent(ps);
const op = st !== null && st.type === FileType.DIRECTORY ? 'rmdir' : 'unlink';
await dispatch(op, ps);
return true;
}
catch (err) {
errors.push(encodeText(`find: cannot delete '${path}': ${refusalWhy(err)}\n`));
return false;
}
}
/**
* How a row's failure is worded: GNU uses the errno text, and a policy
* refusal carries its reason in that place (`frozen`, not the
* `Permission denied` its EACCES code would spell), which is what the
* python twin reads off `strerror or str(exc)`.
*/
function refusalWhy(err) {
if (err instanceof PolicyDenied)
return err.message;
return strerrorOf(err);
}
/**
* Render one accepted row in `find -ls`'s own layout.
*
* The row's facts come from the two entry points the command boundary has: a
* symlink is namespace state no backend can see, so the link view
* answers for one (lstat, as GNU's `-ls` reports the link itself), and
* every other row is statted through the op dispatcher, which answers
* for a mount point and a namespace-only ancestor as well as a backend
* entry. A row that cannot be statted (an earlier `-delete` removed
* it, or the backend refuses it) is GNU's `find: 'path': <reason>`;
* null with a line appended is the caller's signal to end the row's
* chain.
*/
async function rowStat(ps, vfs, statPath, errors) {
const path = ps.rawPath || ps.virtual;
if (statPath === null) {
errors.push(encodeText(`find: '${path}': no stat function\n`));
return null;
}
const link = linkView(vfs ?? undefined)?.statAt(ps.virtual) ?? null;
let st;
try {
st = link ?? (await statPath(ps));
}
catch (err) {
errors.push(encodeText(`find: '${path}': ${refusalWhy(err)}\n`));
return null;
}
if (st === null) {
errors.push(encodeText(`find: '${path}': ${posixPhrase('ENOENT')}\n`));
return null;
}
return st;
}
/** Render one accepted row in `find -ls`'s own layout. */
function lsRow(ps, st, identity) {
const path = ps.rawPath || ps.virtual;
return encodeText(`${formatFindLs(st.with({ name: path }), identity)}\n`);
}
/**
* The facts one `-printf` row renders from, off the stat find holds for
* it. A symlink row is the link itself, and %Y reads what it points at
* through the workspace, so a link into another mount classifies and a
* dangling one reads N.
*/
async function printfFacts(ps, st, vfs) {
const links = linkView(vfs ?? undefined) ?? null;
const link = links !== null && links.statAt(ps.virtual) !== null;
const target = link ? await links.targetStat(ps.virtual) : null;
return {
size: contentSize(st),
kind: link ? 'l' : printfKind(st),
mtimeEpoch: modifiedTs(st.modified) ?? 0,
mode: st.mode,
targetKind: !link ? null : target === null ? 'N' : printfKind(target),
uid: st.uid,
gid: st.gid,
};
}
/** Render one row through a `-printf` format; `st` is null when the
* format names no stat directive. */
function printfRow(action, path, base, st, identity) {
return encodeText(expandPrintf(action.parts, path, base, st, identity));
}
/** Whether an action reads the row's stat: `-ls`, and a `-printf` whose
* format names a stat directive (%s %y %m %T ...). */
function readsStat(action) {
return action.kind === 'ls' || (action.kind === 'printf' && action.stat);
}
/** The spelling a start point's rows are measured from (%P, %d); with no
* operand, the working directory find walked, which prints as `.`. */
function startBase(start) {
return start === undefined ? '.' : start.rawPath || start.virtual;
}
/**
* GNU's `-depth` order over sorted siblings: a directory's contents, each
* sorted, then the directory. The final component is flagged so a path
* sorts after its descendants, whose entry at that depth carries the same
* name unflagged. A start point spelled with a trailing slash prints as
* `d/` while its descendants print as `d/a`, so the slash is dropped
* before splitting: kept, it would leave an empty final component that
* sorts the directory ahead of everything under it, which is the one
* order `-delete` cannot remove a tree in.
*/
export function compareDepthFirst(a, b) {
const pa = rstripSlash(a).split('/');
const pb = rstripSlash(b).split('/');
const n = Math.min(pa.length, pb.length);
for (let i = 0; i < n; i++) {
const byName = compareCodePoints(pa[i] ?? '', pb[i] ?? '');
if (byName !== 0)
return byName;
const fa = i === pa.length - 1 ? 1 : 0;
const fb = i === pb.length - 1 ? 1 : 0;
if (fa !== fb)
return fa - fb;
}
return pa.length - pb.length;
}
/** Whether a row is a mount point or a namespace-only ancestor of one,
* which are not unlinkable entries. Ancestors use the raw mount table
* like isMountRoot: an ungranted mount still pins its ancestors in the
* namespace. */
function structural(path, registry) {
const virtual = path.virtual;
return registry.isMountRoot(virtual) || registry.descendantMounts(virtual).length > 0;
}
/** Whether the actions differ from the implicit print: one explicit
* `-print` is exactly what the backend already rendered, two of them
* print every row twice, as GNU does. */
/**
* Whether the expression's tests made find stat every row it kept. GNU
* reads `-name`, `-path` and `-type` off the directory entry and stats
* only for a test that needs the inode: a size or time window, `-newer`
* and `-empty`.
*/
function testsStat(expr) {
return (expr.minSize !== null ||
expr.maxSize !== null ||
expr.mtimeMin !== null ||
expr.mtimeMax !== null ||
expr.usesEmpty ||
expr.newer.length > 0);
}
function hasActions(expr) {
return expr.actions.length > 1 || expr.actions.some((a) => a.kind !== 'print');
}
/**
* Apply find's actions (-exec / -delete / -print0 / -ls / -printf) to its
* rows.
*
* Per-VFS find handlers only emit matched paths. This dispatcher
* layer re-reads the actions off the expression and applies them per
* match, in the order they were written, the way GNU's implicit `-a`
* chain runs: each per-match `-exec` runs in turn and the first that
* fails ends the chain for that match, so a later `-print` (or `-ls`,
* `-print0`, `-printf`, `-delete`) sees only the matches every earlier
* `-exec` accepted (`-exec grep -q x {} ";" -print`), and `-exec echo {}
* ";" -print -exec echo again {} ";"` alternates the three per match. A
* batched `-exec ... {} +` collects the match at its position and runs
* once after the walk; a failing batch is find's exit 1, as is a row it
* could not delete, list or stat for a `-printf`, and either ends that
* row's chain; a failing per-match run is not, and neither is a command
* that cannot be found, which GNU reports per match and carries on from
* with exit 0. An action other than `-print` suppresses
* the implicit print. `-delete` runs at its position, so a later `-exec`
* sees the row gone, and a row it cannot delete ends the chain with GNU's
* line and find's exit 1. It also turns on `-depth`, which orders every
* directory after its contents, the only order a tree can be removed in;
* `-depth` alone reorders the implicit print the same way, and both
* order one start point's walk at a time: GNU walks each start point to
* completion before the next, so `find b a -depth` prints `b/x b a/y a`
* and `find d d/sub -depth` finishes `d` before it begins `d/sub` again,
* which is why each row names the start point it was found under;
* `-printf`'s %P and %d measure a row from that start point. Returns the rows to print, the stderr to append, and the
* exit status the actions impose (0 when they impose none, even with
* stderr).
*/
export async function applyFindActions(stdout, records, expr, registry, cwd, options = {}) {
const reorders = expr.depthFirst;
if (stdout === null || !(hasActions(expr) || reorders))
return [stdout, new Uint8Array(), 0];
const executeFn = options.executeFn;
const hasExec = expr.actions.some((a) => a.kind === 'exec');
if (hasExec && executeFn === undefined) {
return [null, encodeText('find: -exec: no shell to run the command\n'), 1];
}
const session = options.session ?? getCurrentSession();
const vfs = options.vfs ?? null;
const statPath = options.statPath ?? null;
const dispatch = options.dispatch ?? null;
const identity = options.identity ?? null;
const starts = options.starts ?? [];
const signal = options.signal;
const once = options.stdin === undefined || options.stdin === null ? null : new SharedStdin(options.stdin);
await materialize(stdout);
if (records === null) {
return [null, encodeText('find: actions require structured matches\n'), 1];
}
// The rows arrive in operand order, each naming the start point it was
// found under, whose spelling it carries.
const matches = (reorders
? [...records].sort((a, b) => a.run - b.run ||
compareDepthFirst(a.path.rawPath || a.path.virtual, b.path.rawPath || b.path.virtual))
: records).map((record) => [record.path, startBase(starts[record.run])]);
// An expression with no action of its own prints, which is the one
// implicit action -depth reorders.
const actions = expr.actions.length > 0 ? expr.actions : [{ kind: 'print' }];
const errors = [];
const out = [];
const batches = new Map();
let exitCode = 0;
const stats = actions.some(readsStat);
const statted = testsStat(expr);
const startVirtuals = new Set(starts.length > 0 ? starts.map((s) => s.virtual) : [cwd]);
for (const [match, base] of matches) {
signal?.throwIfAborted();
const path = match.rawPath || match.virtual;
// The stat -ls and -printf render is the one find already holds,
// taken before any action of the chain can remove the row; a row it
// never statted is looked up by the first action that reads it, and
// held from there, as GNU stats a row once.
let held = stats && (statted || startVirtuals.has(match.virtual))
? await rowStat(match, vfs, statPath, [])
: null;
for (const [position, action] of actions.entries()) {
if (action.kind === 'exec') {
if (action.batch) {
const bucket = batches.get(position) ?? [];
bucket.push(path);
batches.set(position, bucket);
continue;
}
if (executeFn === undefined)
break;
if (!(await runExec(executeFn, session, registry, cwd, statPath, action, [path], out, errors, once)))
break;
}
else if (readsStat(action)) {
held = held ?? (await rowStat(match, vfs, statPath, errors));
if (held === null) {
// A row -ls or -printf cannot stat is false, so the chain ends
// for it, as GNU's does.
exitCode = 1;
break;
}
out.push(action.kind === 'printf'
? printfRow(action, path, base, await printfFacts(match, held, vfs), identity)
: lsRow(match, held, identity));
}
else if (action.kind === 'printf') {
out.push(printfRow(action, path, base, null, identity));
}
else if (action.kind === 'delete') {
// A structural row is skipped, not refused, the way Unix leaves
// a mount point in place.
if (structural(match, registry))
continue;
if (!(await deleteRow(match, vfs, dispatch, errors, statPath))) {
exitCode = 1;
break;
}
}
else {
out.push(encodeText(path + (action.kind === 'print0' ? '\0' : '\n')));
}
}
}
for (const [position, action] of actions.entries()) {
const paths = batches.get(position);
if (action.kind !== 'exec' || paths === undefined || executeFn === undefined)
continue;
signal?.throwIfAborted();
if (!(await runExec(executeFn, session, registry, cwd, statPath, action, paths, out, errors, once)))
exitCode = 1;
}
const body = concat(out);
return [body.byteLength > 0 ? body : null, concat(errors), exitCode];
}
//# sourceMappingURL=find_action_dispatch.js.map