UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

915 lines • 45.2 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 { guardIO } from "../../commands/builtin/utils/limit.js"; import { currentCaller, isProgramInvocation, noteRefusal, redirectPathsFor, redirectRunnerFor, redirectSyntaxFor, runWithCaller, runWithRedirectPaths, sessionAccess, } from "../../context/session_context.js"; import { IOResult, materialize } from "../../io/types.js"; import { commandRecords } from "../../observe/context.js"; import { PolicyDenied, postCommandGate, postSessionGate, preSessionGate, refusalOf, renderDeny, resolveProducer, } from "../../policy/index.js"; import { decodeText, encodeText } from "../../shell/bytes.js"; import { CallStack } from "../../shell/call_stack.js"; import { Channel } from "../../shell/console/index.js"; import { CANNOT_EXECUTE_STATUS } from "../../shell/constants.js"; import { ArithError, DiscardSignal, ExitSignal, TooManyMatches } from "../../shell/errors.js"; import { ALIAS_WORD_TYPES, getCommandName, getParts, getText, readRow, splitEnvPrefix, } from "../../shell/helpers.js"; import { WORD_BREAKS } from "../../shell/parse/constants.js"; import { syntaxRefusal } from "../../shell/parse/index.js"; import { NodeType as NT } from "../../shell/types.js"; import { TempEnv, VarAttr } from "../../shell/variable.js"; import { traceCommand } from "../../shell/xtrace.js"; import { PathSpec, wordText } from "../../types.js"; import { mergeSignals } from "../../utils/abort.js"; import { concat } from "../../utils/bytes.js"; import { globPattern } from "../../utils/glob_walk.js"; import { CycleError, globPrefixMatch } from "../../utils/path.js"; import { stripSlash } from "../../utils/slash.js"; import { guardDispatch } from "../abort.js"; import { aliasCommandText, aliasMark, expandingAliases } from "../executor/builtins/alias/index.js"; import { flagHelp } from "../executor/builtins/help/index.js"; import { acceptsLine, handleChgrp, handleChmod, handleChown, handleDf, handleExecPath, handleGetfattr, handleLn, handleMount, handleReadlink, handleSetfattr, handleTouch, operandFollow, prepareMv, stripLinkOperands, } from "../executor/builtins/index.js"; import { BUILTINS } from "../executor/builtins/table.js"; import { fillTreeEnv, handleCommand, madeByCommand } from "../executor/command/command.js"; import { runExternal } from "../executor/command/external.js"; import { argumentListTooLong } from "../executor/command/handler.js"; import { Argv, expandArgv } from "../expand/argv.js"; import { expandBoundaryGlobs } from "../expand/globs.js"; import { expandNode } from "../expand/node.js"; import { Consumer, lookup, runtimeRefused, UNSUPPORTED_BUILTINS } from "../lookup/index.js"; import { sessionEntry, setSessionEntry } from "../session/session.js"; import { ensureVarVisible, gateRendering, seedVar, sessionView, setAttr } from "../session/state.js"; import { ExecutionNode } from "../types.js"; import { admit, Admitted, classifiedWords } from "./admission.js"; import { claimantFor, evaluatedFrom } from "./occurrence.js"; /** * Await an expansion of the command's own words; an `ExitSignal` it raises * names the command, whose redirects bash had not applied. Mirrors Python's * _own_words. */ async function ownWords(node, pending) { try { return await pending; } catch (err) { if (err instanceof ExitSignal) err.expanding = node.id ?? null; throw err; } } /** * The aliases the parser takes for a line an alias rewrote. An alias is no * alias inside the text its own expansion produced, so a name spelling a * reserved word is that word there (`alias fi='if :; then :; fi'` closes its * own `if`). The parser takes one set for the line, so a name that stands in * its own text leaves it. Mirrors Python's parser_aliases. */ export function parserAliases(line, aliases, owners) { return new Set([...aliases].filter((name) => !ownedWord(line, name, owners))); } /** Whether `name` stands as a word of `line` inside text its own expansion produced. */ function ownedWord(line, name, owners) { for (let at = line.indexOf(name); at >= 0; at = line.indexOf(name, at + 1)) { const end = at + name.length; if ((at === 0 || WORD_BREAKS.has(line[at - 1] ?? '')) && (end === line.length || WORD_BREAKS.has(line[end] ?? '')) && owners[at]?.has(name) === true) return true; } return false; } export async function executeCommand(recurse, dispatch, registry, namespace, executeFn, node, context, stdinIn, callStack, jobTable, signal, // The line's parse scope; only alias expansion needs it, and each // expansion parses in a fork released when it ends. Absent means an // alias is stored and printed but never expanded. parser, // The agent the line is attributed to, which an approval request names. // The line's hand-off, which its gate claims on and runs on. handed, // Where a command that runs statements of its own (a function body, a // nested shell) writes them as they finish. sink, // Filled with the words the command expanded to, so the caller knows what // the head ran as. expanded) { const session = context.session; const name = getCommandName(node); const [assignmentNodes, nonPrefixParts] = splitEnvPrefix(getParts(node)); // bash rewrites the head word of a simple command before any other // expansion, textually, and reads the result as a fresh line: an alias // holding a pipe is a pipe. Only an unquoted plain word qualifies, and // `aliasValue` applies the rest of bash's rules (expand_aliases, the // same-line mark, the guards on inserted text). The rewritten line // runs through the same executor with the same call stack, so `$1` // inside a function still means the function's argument. A line run as a // program (`exec`, `env`, `find -exec`) is an argv, which no alias // rewrites. const headNode = nonPrefixParts[0]; if (parser !== undefined && (Object.keys(session.aliasView ?? session.aliases).length > 0 || session.aliasMarks.size > 0) && !isProgramInvocation(session) && headNode?.type === NT.COMMAND_NAME && ALIAS_WORD_TYPES.has(headNode.namedChildren[0]?.type ?? '')) { const head = getText(headNode); const rewrite = aliasCommandText(session, node, headNode, aliasMark(session, readRow(node))); if (rewrite !== null) { const [line, owners] = rewrite; const names = new Set(owners.flatMap((names) => [...names])); const previous = session.aliasExpansion; const scope = parser.fork(); try { const aliases = new Set([...expandingAliases(session), ...names]); const ast = scope.parse(line, parserAliases(line, aliases, owners), session.shopts.extglob ?? false); const io = syntaxRefusal(ast); if (io !== null) { const bad = io.stderr instanceof Uint8Array ? io.stderr : new Uint8Array(); return [ null, io, new ExecutionNode({ command: head, exitCode: io.exitCode, stderr: bad }), ]; } const mapped = [...owners, new Set()]; session.aliasExpansion = { root: ast.id, owners: Array.from(ast.offsets ?? [], (i) => { const owner = mapped[i]; if (owner === undefined) throw new Error('alias source offset is out of bounds'); return owner; }), names, }; // The rewritten line is read from this node, so it runs as a line // of its own under the word that named it: each invocation of one // alias is a place of its own on the line (`c && c` asks twice, as // its spelled-out form does), and what its gates claim is the // line's again at its end. Run on the line's own hand-off, both // reads stood at the same offsets of the same text and the second // ran on the first's nod. const expansion = handed === undefined ? null : evaluatedFrom(node, handed); try { return await runWithRedirectPaths(ast, [], () => recurse(ast, context, stdinIn, callStack ?? new CallStack(), { ...(expansion === null ? {} : { handed: expansion }), ...(sink === undefined ? {} : { sink }), }), null, redirectSyntaxFor(node)); } finally { session.aliasExpansion = previous; if (expansion !== null) registry.decisions.handUp(session.sessionId, expansion); } } finally { scope.release(); } } } const prefixAssignments = []; const view = sessionView(session, registry.policies, context.frame.diagnostics); // A readonly name before a command is refused before its value expands, // on the shell's own stderr, and the command runs without it; with no // command it is a plain assignment, refused once its value expands // (below), and the line is discarded. let refused = ''; for (const p of assignmentNodes) { const atext = getText(p); const eq = atext.indexOf('='); if (eq < 0) continue; const key = atext.slice(0, eq); if (name !== '' && view.isReadonly(key, false)) { refused += `bash: ${key}: readonly variable\n`; continue; } const rawVal = atext.slice(eq + 1); const valNodes = p.namedChildren.filter((c) => c.type !== NT.VARIABLE_NAME); const firstVal = valNodes[0]; const v = firstVal !== undefined ? await ownWords(node, expandNode(firstVal, context, executeFn, callStack, view)) : rawVal; prefixAssignments.push([key, v]); } for (const [k, v] of prefixAssignments) { // The hidden gate runs first, as in setVar: calling a hidden name // "readonly" would leak that it exists. Both branches below write // session.env raw (an `export` inside a function keeps its prefix // past the call), so ungated they would let a narrowed session // clobber the host's value. try { ensureVarVisible(session, k); // ...and `preSession` right after, with the value, because a // prefix assignment is a session write like any other and the form // exports it for the command. Only the hidden half was checked // here, so a deployment refusing `SECRET_*` still saw // `SECRET_K=leak printenv SECRET_K` print the secret: the seeding // below goes through `seedVar`, which is the ungated entry point, so this // loop is the only place the rule can be asked. await preSessionGate(registry.policies, { verb: 'set', key: k, value: v, sessionId: session.sessionId, }); } catch (err) { if (!(err instanceof PolicyDenied)) throw err; return writeRefused(err, name !== '' ? name : k); } if (session.readonlyVars.has(k)) { throw new DiscardSignal(encodeText(`bash: ${k}: readonly variable\n`)); } } // `postSession` asks about each prefix write once it landed. const judgeLanded = async (olds) => { for (const [k, v] of prefixAssignments) { const old = olds.get(k) ?? null; await postSessionGate(registry.policies, { verb: 'set', key: k, value: v, sessionId: session.sessionId }, old === null ? null : gateRendering(old.value)); } }; const restore = (saved) => { for (const [k, prev] of saved) { if (prev === null) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete session.vars[k]; } else { setSessionEntry(session.vars, k, prev); } } }; if (prefixAssignments.length > 0 && name === '') { const olds = new Map(prefixAssignments.map(([k]) => [k, sessionEntry(session.vars, k) ?? null])); for (const [k, v] of prefixAssignments) seedVar(session, k, v); try { await judgeLanded(olds); } catch (err) { if (!(err instanceof PolicyDenied)) throw err; restore(olds); return writeRefused(err, name); } const cmdLabel = prefixAssignments.map(([k, v]) => `${k}=${v}`).join(' '); const landed = [ null, new IOResult(), new ExecutionNode({ command: cmdLabel, exitCode: 0 }), ]; // Redirects among the assignments (`x=1 > f y=2`) still open their // files, and one that fails fails the line; the assignments stand // either way. const runner = redirectRunnerFor(node); if (runner === null) return landed; return runner(() => Promise.resolve(landed), undefined, '', []); } const savedEnvOverrides = new TempEnv(); // Seeded once the command's words are expanded, since bash expands them // with the values from before the assignment: `x=new echo $x` prints the // old x and `IFS=, cmd $v` splits on the old IFS. const seedPrefix = async (command) => { for (const [k, v] of prefixAssignments) { if (!savedEnvOverrides.has(k)) { savedEnvOverrides.set(k, sessionEntry(session.vars, k) ?? null); } // Exported for the duration, which is the whole point of the form: // `TOKEN=x printenv TOKEN` prints `x` because bash puts a prefix // assignment in the *command's environment*, not merely in the // shell. Seeding it plain left it invisible to every reader of // `envSnapshot` — the command's own env, an installed CLI, a guest // runtime — once that view narrowed to the exported set. The saved // record is put back below, so neither the value nor the attribute // outlives the command. seedVar(session, k, v); setAttr(session, k, VarAttr.Export); } // A function runs with the prefix as its temporary environment, a // scope under its own locals: `unset` inside reveals the caller's // value and `export` keeps the name. if (session.functions[command] !== undefined) session.localFrames.push(savedEnvOverrides); // Judged before the command reads them; on a refusal the restore // below puts the old values back. await judgeLanded(savedEnvOverrides); }; if (refused !== '' && sink !== undefined) { await sink.emit(Channel.STDERR, encodeText(refused)); refused = ''; } let settling = null; try { const [stdout, io, execNode] = await runCommandBody(recurse, dispatch, registry, namespace, executeFn, node, nonPrefixParts, context, stdinIn, callStack, jobTable, signal, handed, seedPrefix, sink, parser, expanded, new Set(prefixAssignments.map(([k]) => k))); if (refused !== '') io.stderr = concat([encodeText(refused), await materialize(io.stderr)]); // A run that starts as its output is read still runs under the prefix, // so the lines it shells out see it: the prefix ends as that output // settles. if (prefixAssignments.length > 0 && io.output?.settled === false) settling = io.output; return [stdout, io, execNode]; } catch (err) { if (!(err instanceof TooManyMatches)) throw err; // The command a glob was given to does not run, as exec(2) refuses an // argument list too long; the line goes on. const stderr = argumentListTooLong(name, err.reason); return [ null, new IOResult({ exitCode: CANNOT_EXECUTE_STATUS, stderr }), new ExecutionNode({ command: name, exitCode: CANNOT_EXECUTE_STATUS, stderr }), ]; } finally { const frames = session.localFrames; if (frames[frames.length - 1] === savedEnvOverrides) frames.pop(); if (settling !== null) { settling.callbacks.push(() => { restore(savedEnvOverrides); }); } else { restore(savedEnvOverrides); } } } /** * The node entries a glob `rm` never removed: those whose name the pattern * matched is one the session cannot see, which the backend's expansion met as * absent. Empty for a session that hides nothing. Mirrors Python's _unmet. */ function unmet(namespace, session, pattern) { const links = namespace.sessionLinks(session); if (!links.hidesUnder('/')) return new Set(); const depth = stripSlash(pattern).split('/').length; const kept = new Set(); for (const path of namespace.nodes.keys()) { if (!globPrefixMatch(path, pattern)) continue; if (!links.visible(`/${stripSlash(path).split('/').slice(0, depth).join('/')}`)) kept.add(path); } return kept; } /** * What `rm` meets as absent, read before it runs: per glob operand the node * entries it never removes (`unmet`), and the literal operands the session * cannot see. Read afterwards, a link hidden only because its walk passed * through a directory the line removed reads as a visible dangling link, and * its node would go. Mirrors Python's _rm_unseen. */ function rmUnseen(namespace, session, operands) { const links = namespace.sessionLinks(session); const kept = new Map(); const hidden = new Set(); for (const item of operands) { if (!(item instanceof PathSpec)) continue; if (item.pattern !== null) { const pattern = globPattern(item.virtual); kept.set(pattern, unmet(namespace, session, pattern)); } else if (!links.visible(item.virtual)) { hidden.add(item.virtual); } } return { kept, hidden }; } /** A prefix assignment a session policy refused: bash's line, exit 1. */ function writeRefused(err, command) { const stderr = encodeText(`bash: ${err.message}\n`); return [ null, new IOResult({ exitCode: 1, stderr }), new ExecutionNode({ command, exitCode: 1, stderr }), ]; } async function runCommandBody(recurse, dispatch, registry, namespace, executeFnIn, node, parts, context, stdinIn, callStack, jobTable, signalIn, handed, seedPrefix, sink, parser, expanded, assigned = new Set()) { const session = context.session; const stdin = stdinIn; // A background job's kill channel rides the session; fold it in so // builtins (sleep) and the mount layer observe the kill. const signal = mergeSignals(signalIn, context.frame.abortSignal); // The command's place on the line, as the pass computed it, and the // entry point its nested evaluations re-enter through: a word that runs a // line (eval, source, xargs) is bound to this node, and a substitution // names its own node when it calls, so every nested line stands under // the node its text came from. const claimant = claimantFor(node, handed); const executeFn = (cmd, opts) => executeFnIn(cmd, { node, ...opts }); const argv = await ownWords(node, expandArgv(parts, context, executeFn, callStack, registry, namespace.sessionLinks(session), sessionView(session, registry.policies, context.frame.diagnostics))); if (expanded !== undefined) expanded.words = [argv.name, ...argv.args]; // Capture xtrace before the body runs so `set -x` itself is not // traced (bash enables tracing only for the following commands). A body // that writes as it runs is traced before it starts. let xtrace = session.shellOptions.xtrace === true && argv.name !== ''; if (xtrace && sink !== undefined) { await sink.emit(Channel.STDERR, traceCommand([argv.name, ...argv.args])); xtrace = false; } const [ranStdout, io, execNode] = await runArgv(recurse, dispatch, registry, namespace, executeFn, argv, context, stdin, callStack, jobTable, signal, readRow(node), redirectPathsFor(node), claimant, sink, redirectRunnerFor(node), parser, seedPrefix, assigned); let stdout = ranStdout; if (io.producer === null && argv.name !== '') { // Builtins and other non-mount routes return no rider; stamp the // expanded name here so postExecute policies keyed on a command // (echo, printf, ...) still see it. io.producer = { command: argv.name, prefixes: [], declared: null }; } if (!io.outputFinalized) { io.outputFinalized = true; if (session.terminalOutput && (session.execStdout === null || session.execStdout === '&1') && io.producer !== null) { const bound = resolveProducer(io.producer, (prefix) => registry.limitMount(prefix), registry.commandLimits, session.commandLimits); stdout = guardIO(stdout, io, bound, io.producer.command); execNode.exitCode = io.exitCode; } } if (xtrace) { const existing = await materialize(io.stderr); io.stderr = concat([traceCommand([argv.name, ...argv.args]), existing]); } return [stdout, io, execNode]; } async function runArgv(recurse, dispatch, registry, namespace, executeFn, argv, context, stdin, callStack, jobTable, signal, // The row the shell began reading the command on within its parse // (`readRow`), which only `alias`, `unalias` and `shopt` read: the // commands of one read keep the aliases it began with. row = 0, // The agent the line is attributed to, which an approval request names. // The statement's expanded redirect targets, judged with the line // because their I/O runs on the shell's own fds outside the admitted // command's gate window. redirects = [], // The line's hand-off, which its gate claims on and runs on. claimant = null, sink, // Applies ordered redirects after command word expansion. runner = null, parser, seedPrefix, assigned = new Set()) { const session = context.session; const name = argv.name; // A glob whose directory holds a child mount cannot be pushed down to // one backend: the mount root is a child of that directory but its keys // live in another VFS, so the backend reports "no such file" for a // name its own listing shows. Expanding such a word here lets the // matches route per mount. It has to happen before the admission // policies below, not just before the follow policy: a word left // unexpanded reaches preCommand as the literal pattern, and // MountRootPolicy cannot recognize a mount root inside one, so // `tar -cf out.tar /base/*` would archive a whole backend the same // operand typed by hand is refused for. const refusedExternal = runtimeRefused(name, session, registry, context.frame.routing); const boundary = refusedExternal ? [...argv.operands] : await expandBoundaryGlobs(argv.operands, registry, namespace.sessionLinks(session), sessionAccess(session)); const expandedWords = boundary.map(wordText); // Compared as words, not as a count: a glob that matches exactly one // name (`du /base/i*` where only the mount root matches) is still an // expansion, and dropping it routes the pattern to a backend that // cannot serve the child mount's keys. const typedWords = argv.operands.map(wordText); if (expandedWords.length !== typedWords.length || expandedWords.some((w, i) => w !== typedWords[i])) { argv = new Argv(argv.name, expandedWords, boundary, argv.prefix); } // Ahead of every admission of the command: the gate walks a mount's tree // under the managed values its run reads. A first fetch changes how the // words read (an env-filled required option lets the walk reach the leaf, // whose files are paths), so they are classified again. if (name !== '' && (await fillTreeEnv(name, argv.args, session, registry, assigned))) { const words = await classifiedWords(name, argv.args, session, registry); argv = new Argv(argv.name, argv.args, words.slice(1), argv.prefix); } let admitted = null; // What the last admission was asked about: the redirect layer admits // each target before it opens it, and then the run admits the whole // statement, so the policies are asked again only for a target they // have not seen. let asked = null; const guard = async (paths, given) => { const key = [String(given === null), ...paths.map((p) => p.virtual)].join('\0'); if (admitted !== null && asked === key) return null; if (name !== '') { asked = key; const verdict = await admit(name, [...argv.args], [...argv.operands], context.session, registry, namespace, given, paths, signal, claimant); if (!(verdict instanceof Admitted)) { return [ null, new IOResult({ exitCode: verdict.exitCode, stderr: verdict.stderr, refusal: verdict.refusal, }), new ExecutionNode({ command: [name, ...argv.args].join(' '), stderr: verdict.stderr, exitCode: verdict.exitCode, refused: true, }), ]; } admitted = verdict; } return null; }; const runRedirected = async (given, output, paths) => { try { await seedPrefix?.(name); } catch (err) { if (!(err instanceof PolicyDenied)) throw err; return writeRefused(err, name); } const refusal = await guard(paths, given); if (refusal !== null) return refusal; // The admitted command's gate is bound for its run and handed back // after, so its own I/O can ask about the entries the gate did not see // and a nested line binds its own (see `Admitted`). The workspace's // policies bind in the same window, whether or not a gate judged the // line, so the command tier's policy guard can fire preVfs for the // backend I/O a handler performs. const route = () => routeArgv(recurse, dispatch, registry, namespace, executeFn, argv, context, given, callStack, jobTable, signal, row, claimant?.line ?? null, output, parser, admitted); // A post method reads what the command did: its file records, gathered // as it runs and as its output drains, and its result node, settled // once the output has drained. const gated = admitted; const policies = registry.policies; const sessionId = context.session.sessionId; const judging = gated !== null && (await policies.wantsFor('postCommand', sessionId)); const watched = judging || (await policies.wantsFor('postExecute', sessionId)); let mine = []; // The command's own layer of the caller: once admitted, its gate, and // no declared limit until its mount binds its own, so a command a // registered one runs keeps none of that one's. const outer = currentCaller(); const result = await runWithCaller({ ...outer, gate: gated ?? outer.gate, command: argv.name, invocation: gated?.ctx ?? null, declared: null, }, () => commandRecords((records) => { mine = records; return route(); }, true)); if (!watched) return result; const [stdout, io, node] = result; const early = io.stderr ?? new Uint8Array(); const saved = node.stderr; // postCommand judges the command once its result is final: a streamed // output settles its exit code and the stderr it adds as it drains // (unless the command saved that itself, as an external one does), so the // method is asked when the stream ends or its reader stops, and at once // for output already whole. A refusal leaves what was sent as sent: exit // 126, the refusal line on stderr, the refusal record. const settle = async () => { // A function hands back its last command's node, which keeps the // records that command made. node.exitCode = io.exitCode; for (const item of mine) node.records.push(item); const late = io.stderr; if (node.stderr === saved && early instanceof Uint8Array && late instanceof Uint8Array && late.byteLength > early.byteLength && early.every((byte, i) => late[i] === byte)) node.stderr = concat([node.stderr, late.subarray(early.byteLength)]); if (!judging) return; const ctx = gated.ctx; const stderr = await materialize(io.stderr); io.stderr = stderr; const action = await postCommandGate(policies, { ...ctx, exitCode: io.exitCode, stderr: new TextDecoder().decode(stderr), records: mine.map((r) => r.toDict()), }, claimant, signal); if (action === null) return; const [err, code] = renderDeny(ctx.command, action); io.stderr = concat([stderr, err]); io.exitCode = code; node.exitCode = code; node.stderr = concat([node.stderr, err]); io.refusal = refusalOf(action); if (io.refusal !== null) noteRefusal(io.refusal); }; if (stdout === null || stdout instanceof Uint8Array) { await settle(); return result; } const pulled = stdout[Symbol.asyncIterator](); const drained = async function* () { let done = false; try { for (;;) { // Each pull runs the command's lazy reads; they are its own, // whoever is reading. const next = await commandRecords(async (more) => { try { return await pulled.next(); } finally { for (const item of more) mine.push(item); } }, true); if (next.done === true) { done = true; return; } yield next.value; } } finally { if (!done) await pulled.return?.(); await settle(); } }; return [drained(), io, node]; }; if (runner !== null) return runner(runRedirected, guard, name, argv.args); return runRedirected(stdin, sink, redirects); } // Drop the refusal lines the command tier already wrote. // // A mount-mode refusal names the mount, not the operand, so the line the // node table wrote for a refused link is the very line Mount.runCommand // writes for the backend operands beside it on the same mount, and // `rm dlink file` would say it twice. Compared on the trimmed text, so a // trailing-newline difference between the two renderers cannot defeat // it. Mirrors Python's unsaid. export function unsaid(lines, said) { if (said.byteLength === 0) return [...lines]; const spoken = new Set(decodeText(said) .split('\n') .map((t) => t.trim())); return lines.filter((line) => !spoken.has(line.trim())); } async function routeArgv(recurse, dispatchIn, registry, namespace, executeFn, argv, context, stdin, callStack, jobTable, signal, row, handed, sink, parser, admission = null) { const session = context.session; // The half of `runArgv` past the gate, split out so the gate's verdict // can be bound around it. // Every handler below reaches the dispatcher through this one function, // so a line whose caller was already released starts no further op // between its operands (`rm l1 l2` with the first unlink held past // the grace), and a walk the index answers yields often enough for the // abort or timeout to fire. const dispatch = guardDispatch(dispatchIn, mergeSignals(signal, context.frame.abortSignal)); const name = argv.name; const args = [...argv.args]; let operands = [...argv.operands]; // Path execution: bash hands a slash-carrying head word to the // loader, never to command lookup: no builtin, function, or CLI can // claim it. After the admission gate so a policy sees the line like // any other. if (name.includes('/')) { return handleExecPath(dispatch, executeFn, name, args, context, registry, namespace, stdin, sink, jobTable ?? undefined, callStack); } // Unsupported bash builtins. Constructs the parser accepts but the // executor cannot honor. Returning a clear error lets LLMs detect a // capability gap instead of treating it as a missing binary. if (UNSUPPORTED_BUILTINS.has(name)) { const err = encodeText(`mirage: unsupported builtin: ${name}\n`); return [ null, new IOResult({ exitCode: 2, stderr: err }), new ExecutionNode({ command: name, exitCode: 2, stderr: err }), ]; } const routing = context.frame.routing; const consumer = lookup(name, session, registry, routing); if (consumer === Consumer.EXTERNAL) { if (routing === undefined) throw new Error(`${name}: run outside a routed line`); return runExternal(argv, stdin, session, registry, routing, signal, sink); } // `--help` as a builtin's first word: bash answers it with the builtin's // help and status 2 before the builtin reads its options, the job // builtins included. A program run (`find -exec printf --help`) is the // program's own, not this. if (argv.args[0] === '--help' && !isProgramInvocation(session)) { const answered = flagHelp(name); if (answered !== null) return answered; } // Shell builtins. One lookup: every executor-run builtin word maps to // a handler that takes the whole invocation, so the arms live beside // their workers (builtins/<word>/) rather than here. Job builtins and // the interpreters are not in the table; they route below. const builtin = BUILTINS.get(name); if (builtin !== undefined) { try { return await builtin({ argv, context, stdin, callStack, signal, row, dispatch, registry, namespace, executeFn, ...(parser === undefined ? {} : { parser }), ...(sink === undefined ? {} : { sink }), ...(jobTable === null ? {} : { jobTable }), }); } catch (err) { // A value a builtin stores into an `-i` name did not evaluate: bash // discards the line in that builtin's voice (`bash: getopts: ?: // syntax error: ...`). if (err instanceof ArithError) throw err.signal(name, true); throw err; } } // Pathname resolution (POSIX, `operandFollow`), ahead of every handler // below because the kernel resolves a path before the syscall, not inside // it. An operand a link loop stands in comes back refused (`walkError`) // rather than failing the line: the command meets ELOOP at its op and // words it per operand. if (namespace.nodes.size > 0 && operands.length > 0) { operands = await operandFollow(namespace.sessionLinks(session), dispatch, name, argv.words, session.cwd)(operands); argv = argv.withOperands(operands); } // The programs below run here rather than on a mount, and every op one // issues is the program's, as a mount command's is: a rule scoped to it // reaches what `chmod -R` walks into, not only its operands. const made = madeByCommand(dispatch, admission); // Symlinks are namespace-backed: not bash builtins, not mount commands. // They mutate the addressing layer. `readlink -f/-e/-m` is canonicalization, // which falls through to the mount command. if (name === 'ln') { return await handleLn(namespace, made, session, operands); } if (name === 'readlink') { return await handleReadlink(namespace, made, session, operands); } // Extended attributes: the dispatcher's node table and the backend's own // facts; they read -h themselves. if (name === 'getfattr') { return await handleGetfattr(namespace, made, session, operands); } if (name === 'setfattr') { return await handleSetfattr(made, session, operands); } // Metadata commands (namespace-routed: resolve-then-setattr with // overlay fallback; they run their own link follow). if (name === 'chmod') { return handleChmod(namespace, made, session, operands); } if (name === 'chown') { return handleChown(namespace, made, session, operands); } if (name === 'chgrp') { return handleChgrp(namespace, made, session, operands); } if (name === 'touch') { return handleTouch(namespace, made, session, operands); } // Capacity (registry-routed: enumerates mounts, reports per-mount capacity; // never fabricates numbers). if (name === 'df') { return handleDf(registry, session, made, operands); } if (name === 'mount') { return handleMount(registry, session, operands); } // Symlink-aware dispatch: reads follow links (open(2)); rm/mv act on // the link entry itself (lstat semantics). let linkErrors = []; let dispatchArgv = argv; if (namespace.nodes.size > 0) { try { // Both remove the link entry itself, which no backend can see; // unlink(1) is rm(1) restricted to one non-directory. Gated on the // line being one the command layer accepts, because this removal // happens before that layer parses and it cannot be taken back // (GNU refuses `rm --bogus dlink` and `unlink dlink other` with // the link still there). if ((name === 'rm' || name === 'unlink') && acceptsLine(name, argv.args, operands, session.cwd)) { const [rest, handled, stripErrors] = await stripLinkOperands(name, dispatch, namespace.sessionLinks(session), operands, argv.args, session.cwd); operands = rest; linkErrors = stripErrors; if (handled > 0 && !rest.some((a) => a instanceof PathSpec)) { if (linkErrors.length === 0) { return [null, new IOResult(), new ExecutionNode({ command: name, exitCode: 0 })]; } const err = encodeText(linkErrors.join('')); return [ null, new IOResult({ exitCode: 1, stderr: err }), new ExecutionNode({ command: name, exitCode: 1, stderr: err }), ]; } } else if (name === 'mv') { const prepared = await prepareMv(namespace.sessionLinks(session), dispatch, operands, argv.args, session.cwd); operands = prepared.items; if (prepared.early !== null) return prepared.early; } } catch (err) { if (err instanceof CycleError) { const errBytes = encodeText(`${name}: ${err.path}: Too many levels of symbolic links\n`); return [ null, new IOResult({ exitCode: 1, stderr: errBytes }), new ExecutionNode({ command: name, exitCode: 1, stderr: errBytes }), ]; } throw err; } dispatchArgv = argv.withOperands(operands); } // What rm's bookkeeping may drop is read before rm runs: removing a // directory can leave a hidden link dangling, which the session would see // afterwards, though rm met it as absent. const unseen = name === 'rm' && namespace.nodes.size > 0 ? rmUnseen(namespace, session, operands) : { kept: new Map(), hidden: new Set() }; // Default: mount-dispatched command const [source, io, execNode] = await handleCommand(recurse, dispatch, registry, dispatchArgv.words, context, stdin, callStack, jobTable, namespace, executeFn, handed ?? null, signal, sink, parser, admission); const stdout = linkErrors.length > 0 ? await materialize(source) : source; if (io.exitCode === 0 && namespace.nodes.size > 0) { if (name === 'rm') { // A removed path takes its node meta (overlay attrs) with it; a // removed dir purges everything underneath. Glob operands reach // here unexpanded (backend wrappers expand them), so the node // table matches the pattern itself. A name the session cannot see // was never removed: rm met it as absent (-f silenced that), so its // node stays. for (const item of operands) { if (!(item instanceof PathSpec)) continue; // The walk refused it, so rm removed nothing there (-f only // silenced the refusal), and the empty name's `virtual` is the // working directory: purging under it dropped every link the // directory held. if (item.walkError !== null) continue; // A trailing slash asked for the directory, and rm refused (or -f // silenced the refusal). Nothing was removed, so nothing may be // purged: dropping the node here deleted the very link the slash // protects (GNU keeps it through `rm -rf dlink/`). if (item.rawPath.endsWith('/')) continue; if (item.pattern !== null) { // A quoted metacharacter is a literal here too, so the node // table is matched with the same pattern the backend resolved // with. const pattern = globPattern(item.virtual); await namespace.unlinkGlob(pattern, unseen.kept.get(pattern)); } else if (!unseen.hidden.has(item.virtual)) { await namespace.unlink(item.virtual); await namespace.purgeUnder(item.virtual); } } } } if (linkErrors.length > 0) { // A refused link operand fails the line the way a refused backend // operand does: its lines lead (they were reported first) and any // success stays a partial one. Merged after the bookkeeping above // so the operands the backend did remove still shed their node // meta. const tail = io.stderr instanceof Uint8Array ? io.stderr : new Uint8Array(0); io.stderr = concat([encodeText(unsaid(linkErrors, tail).join('')), tail]); if (io.exitCode === 0) io.exitCode = 1; const nodeTail = execNode.stderr; execNode.stderr = concat([encodeText(unsaid(linkErrors, nodeTail).join('')), nodeTail]); if (execNode.exitCode === 0) execNode.exitCode = 1; } return [stdout, io, execNode]; } //# sourceMappingURL=command_dispatch.js.map