UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

417 lines • 19.7 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 { 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