UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

666 lines • 35.6 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 { RELAY_COMMANDS } from "../../../commands/builtin/generic/crossmount/constants.js"; import { aggregateFor } from "../../../commands/builtin/generic/crossmount/detect.js"; import { handleCrossMount, isCrossMount, } from "../../../commands/builtin/generic/crossmount/index.js"; import { prepareProgram, PROGRAM_FILE_COMMANDS, programFiles, valueRefusal, } from "../../../commands/builtin/generic/program.js"; import { identityFrom } from "../../../commands/builtin/utils/identity.js"; import { invokedEnvNames, suppliedEnvNames } from "../../../commands/cli/walk.js"; import { UsageError } from "../../../commands/errors.js"; import { isBuiltinGrammar, registeredSpec } from "../../../commands/spec/builtins.js"; import { STANDARD_AFTER_SCAN } from "../../../commands/spec/constants.js"; import { spreadOperands } from "../../../commands/spec/flag_view.js"; import { SPECS } from "../../../commands/spec/index.js"; import { standardRequest } from "../../../commands/spec/standard.js"; import { currentCaller, getAdmission, sessionAccess, } from "../../../context/session_context.js"; import { IOResult, materialize } from "../../../io/types.js"; import { parseFind } from "../../../rs/find.js"; import { encodeText } from "../../../shell/bytes.js"; import { NOT_FOUND_STATUS } from "../../../shell/constants.js"; import { Follow, PathSpec } from "../../../types.js"; import { abortable, mergeSignals } from "../../../utils/abort.js"; import { concat } from "../../../utils/bytes.js"; import { compareCodePoints } from "../../../utils/sort.js"; import { globOptions, resolveGlobs } from "../../expand/globs.js"; import { Files } from "../../files.js"; import { EXEC_WRAPPERS } from "../../lookup/constants.js"; import { Consumer, JOB_BUILTINS, lineFacts, lookup, runsAs } from "../../lookup/index.js"; import { MountCommandAmbiguous } from "../../mount/errors.js"; import { pathStat } from "../../mount/namespace/probe.js"; import { MountCommandUnsupported } from "../../mount/registry.js"; import { makeCheckUnlink, makeStorageKey } from "../../mount/storage.js"; import { sessionEntry } from "../../session/session.js"; import { sessionView } from "../../session/state.js"; import { ExecutionNode } from "../../types.js"; import { operandFollow } from "../builtins/links/links.js"; import { fanOutTraversal, runWithFanout, shouldFanOut } from "../fanout.js"; import { applyFindActions } from "../find_action_dispatch.js"; import { resolveNewerRefs } from "../find_refs.js"; import { handleDisown, handleFg, handleJobs, handleKill, handlePs, handleWait } from "../jobs.js"; import { handleCli, handleMountCli, reachesService, walkLeaf } from "./cli.js"; import { optionError, parseFlags } from "./flags.js"; import { runShellFunction } from "./functions.js"; import { CWD_DEFAULT_RAW, defaultCwdOperand, mergeScopes, operandPaths, optionLoopExits, pathFlagScopes, routableScopes, routedOperands, } from "./routing.js"; import { execNode, findStartPoints, nestedShell, runOnMount } from "./run.js"; import {} from "./types.js"; // One handler per JOB_BUILTINS member but ps, which also takes the workspace // user; lookup already narrowed the name. const JOB_HANDLERS = { wait: handleWait, fg: handleFg, kill: handleKill, jobs: handleJobs, disown: handleDisown, }; /** * Apply find's actions once, at the command boundary. Every runner below * this point (one mount, a fan-out over nested mounts, one native run per * cross-mount operand) only selects rows and hands them back as * `io.records`; the actions run here over all of them together, so * a batched `-exec {} +` is one invocation across every start point, as * GNU's is, and a per-match action runs in start-point order. */ async function finishFind(stdout, io, expr, registry, context, executeFn, vfs, statPath, dispatch, stdin, starts, signal) { const session = context.session; const [newStdout, actionErr, actionExit] = await applyFindActions(stdout, io.records, expr, registry, session.cwd, { ...(executeFn !== undefined ? { executeFn } : {}), session, vfs, statPath, dispatch, identity: identityFrom(sessionView(session, registry.policies, context.frame.diagnostics)), stdin, starts, ...(signal !== undefined ? { signal } : {}), }); const warned = expr.warnings.map((line) => encodeText(`${line}\n`)); if (warned.length > 0 || actionErr.length > 0) { const existing = await materialize(io.stderr); io.stderr = concat([...warned, existing, actionErr]); } if (io.exitCode === 0) io.exitCode = actionExit; return newStdout; } /** * Each path operand with what the cross-mount decision reads of it: the * mount that owns it, the reducer of what `cmdName` resolves to there, and * whether a mount sits below it. Mirrors Python's `_operand_mounts`. */ function operandMounts(registry, cmdName, scopes) { return scopes.map((scope) => { const mount = registry.tryMountFor(scope.virtual); return { path: scope, mount: mount?.prefix ?? null, aggregate: mount?.resolveCommand(cmdName)?.aggregate ?? null, nests: registry.descendantMounts(scope.virtual).length > 0, }; }); } /** The command's words as the line spelled them, an operand as typed. * Mirrors Python's spelled_words. */ function spelledWords(parts) { return parts.map((p) => (p instanceof PathSpec ? p.rawPath : p)); } /** * Fetch the managed names the tree a mount serves under `name` reads. The * line's fill covers installed CLIs only: which mount's tree a head word * reaches is known only when the command runs, after any `cd` before it on the * line. The names are fetched ahead of the command's admission, as an * installed CLI's are, so the gate walks the tree under the values the run * reads; an env-filled required option would otherwise stop that walk short * of the leaf, and a writing leaf would read as a reader. A name the * invocation assigns (`TOKEN=x tok`, `skip`) is the invocation's, so it is * never fetched. A failed fetch throws SecretsError. Resolves to whether a * value was fetched, which changes how the words read. Mirrors Python's * `fill_tree_env`. */ export async function fillTreeEnv(name, args, session, registry, skip = new Set()) { if (registry.fillEnv === undefined || registry.clis.get(name) !== null) return false; const candidates = registry.invocationCandidates(name, [], session.cwd, args, sessionAccess(session)); if (candidates.length !== 1) return false; const tree = candidates[0]?.cliFor(name) ?? null; if (tree === null || lookup(name, session, registry) !== Consumer.MOUNT) return false; const verbs = new Set(args.filter((w) => !w.startsWith('-'))); const supplied = suppliedEnvNames(tree.spec, [...args]); const names = new Set([...invokedEnvNames(tree.spec, verbs)].filter((n) => !supplied.has(n) && !skip.has(n))); const before = new Map([...names].map((n) => [n, sessionEntry(session.vars, n)])); await registry.fillEnv(session, names); return [...names].some((n) => sessionEntry(session.vars, n) !== before.get(n)); } /** * The dispatch a command's own ops go through: every op it issues is made by * the command, its admission gate included, so a rule scoped to the command * reaches the entries below its operands, and a CLI's own reads and every * mount run below (one run, a fan-out or a cross-mount) included. Mirrors * Python's made_by_command. */ export function madeByCommand(dispatch, admission) { return (op, path, args, kwargs, report, caller) => dispatch(op, path, args, kwargs, report, caller ?? { ...currentCaller(), ruleGate: admission }); } export async function handleCommand(executeNode, dispatch, registry, parts, context, stdin = null, callStack = null, jobTable = null, namespace, executeFn, handed = null, signal, // Where a function body writes its statements as they finish. sink, parser, admission = getAdmission()) { const session = context.session; if (parts.length === 0) { return [null, new IOResult(), new ExecutionNode({ command: '', exitCode: 0 })]; } const head = parts[0]; if (head === undefined) { return [null, new IOResult(), new ExecutionNode({ command: '', exitCode: 0 })]; } let cmdName = typeof head === 'string' ? head : head.virtual; const cmdStr = parts.map((p) => (typeof p === 'string' ? p : p.virtual)).join(' '); if (JOB_BUILTINS.has(cmdName) && jobTable !== null) { const textParts = parts.map((p) => (typeof p === 'string' ? p : p.virtual)); if (cmdName === 'ps') { return handlePs(jobTable, textParts, session, sessionView(session, registry.policies)); } const handler = JOB_HANDLERS[cmdName]; if (handler !== undefined) { return handler(jobTable, textParts, session, sessionView(session, registry.policies, context.frame.diagnostics), mergeSignals(signal, context.frame.abortSignal), // `fg` is the one job builtin that writes before it blocks. sink); } } const funcBody = session.functions[cmdName]; if (funcBody !== undefined) { if (parser === undefined) throw new Error('function invocation requires a parse scope'); return runShellFunction(executeNode, cmdName, funcBody, parser, parts.slice(1), context, stdin, callStack, jobTable, handed, registry.decisions, sink, executeFn ?? null); } dispatch = madeByCommand(dispatch, admission); // The command's own files keep no ledger: the dispatcher accounts for the line. const commandFiles = (follow) => new Files(dispatch, namespace === undefined ? null : namespace.readers(() => session.visibility), null, { follow, ledger: false }); // Installed CLIs: dispatch by name, never by operand path. Sits // below functions (a user can wrap an installed CLI, bash-style) // and above every mount branch (a CLI consults no mount). // A CLI that works on files rather than an API (`git`) reaches them // through `ctx.vfs`, the session's files made by the command; the // rest never look. const cliInstall = registry.clis.get(cmdName); if (cliInstall !== null) { const cliSignal = mergeSignals(signal, context.frame.abortSignal); // A leaf that waits on its service keeps running; the caller's abort // releases the invocation, as it does for `wait`. return abortable(handleCli(cliInstall, parts, session, stdin, { ...(executeFn !== undefined ? { shell: nestedShell(executeFn, session, cliSignal) } : {}), ...(cliSignal !== undefined ? { signal: cliSignal } : {}), commandLimits: registry.commandLimits, ids: registry.ids, entries: registry.runtimeEntries, vfs: commandFiles(null), session: sessionView(session, registry.policies, context.frame.diagnostics), ...(registry.processView === undefined ? {} : { processes: registry.processView(session) }), ...(context.frame.routing === undefined ? {} : { routing: context.frame.routing }), settle: (runtime) => registry.settle(runtime), }, reachesService(cliInstall.cli) ? () => registry.dropCaches(cliInstall.accessor) : null), mergeSignals(signal, context.frame.abortSignal)); } // A wrapper execs the program it names, past every function and CLI; the // session's allow list judges the name it typed. const typed = cmdName; const wrapped = runsAs(cmdName, registry) !== cmdName ? EXEC_WRAPPERS.get(cmdName) : undefined; if (wrapped !== undefined) { const [runs, option] = wrapped; cmdName = runs; parts = [runs, option, ...parts.slice(1)]; } const follow = namespace !== undefined ? operandFollow(namespace.sessionLinks(session), dispatch, cmdName, parts, session.cwd) : undefined; const files = commandFiles(follow ?? null); const runCtx = { admission, ...(signal !== undefined ? { signal } : {}), ...(follow !== undefined ? { follow } : {}), registry, context, dispatch, ...(namespace !== undefined ? { namespace } : {}), ...(executeFn !== undefined ? { executeFn } : {}), }; // Unknown name: nobody registers it; fail like bash before any // backend work. The admission policies (fired upstream at the // dispatch chokepoint) stay ahead of this so // protective refusals keep their specific messages. if (lookup(typed, session, registry) === Consumer.UNKNOWN) { const errBytes = encodeText(`${typed}: command not found\n`); return [ null, new IOResult({ exitCode: NOT_FOUND_STATUS, stderr: errBytes }), new ExecutionNode({ command: cmdStr, exitCode: NOT_FOUND_STATUS, stderr: errBytes }), ]; } const runTree = async (mount, cli, prepared) => { await mount.ensureReady(); const treeSignal = mergeSignals(signal, context.frame.abortSignal); return handleMountCli(mount, cli, parts, session, stdin, { ...(executeFn !== undefined ? { shell: nestedShell(executeFn, session, treeSignal) } : {}), ...(treeSignal !== undefined ? { signal: treeSignal } : {}), commandLimits: registry.commandLimits, ids: registry.ids, vfs: files, session: sessionView(session, registry.policies, context.frame.diagnostics), ...(registry.processView === undefined ? {} : { processes: registry.processView(session) }), }, (path) => registry.tryMountFor(path), follow, prepared); }; // A command tree a mount serves runs by name on that mount, like an // installed CLI: its walk, not a spec parse, reads the words, so it sits // ahead of the help, cross-mount and flag-parsing branches below, and of // the operand a bare ls, du, tree, find or grep implies. let treeMount = registry.invocationMount(cmdName, [], session.cwd, spelledWords(parts).slice(1), sessionAccess(session)); const tree = treeMount?.cliFor(cmdName) ?? null; if (treeMount !== null && tree !== null) { const discovered = treeMount; const walked = await walkLeaf(cmdName, tree, parts, session, files); if (Array.isArray(walked) && walked[1].exitCode === 0) return walked; try { treeMount = registry.executionMount(cmdName, [], session.cwd, spelledWords(parts).slice(1), sessionAccess(session)); } catch (err) { if (!(err instanceof MountCommandAmbiguous)) throw err; const stderr = encodeText(`${err.message}\n`); return [ null, new IOResult({ exitCode: 1, stderr }), new ExecutionNode({ command: cmdStr, exitCode: 1, stderr }), ]; } if (Array.isArray(walked)) return walked; const selectedTree = treeMount?.cliFor(cmdName) ?? null; if (treeMount !== null && selectedTree !== null) { return runTree(treeMount, selectedTree, treeMount === discovered ? walked : undefined); } } // A mount may register its own command under a builtin's name. The steps // below that read the line with the builtin's grammar then skip a line // whose operands (or cwd) pick such a mount, as resolveMount picks it, and // the line routes by those operands alone. Only a word classified as a path // in a path operand slot picks one: not a file an option reads (`grep -f`), // a pattern, or a word the cwd's spec left as text. const operands = operandPaths(cmdName, parts.slice(1), session.cwd).filter((s) => s.walkError !== 'ENOENT'); const ownMount = registry.invocationMount(cmdName, operands, session.cwd, spelledWords(parts).slice(1), sessionAccess(session)); const ownSpec = ownMount?.specFor(cmdName) ?? null; const borrowed = SPECS[cmdName] !== undefined && ownSpec !== null && !isBuiltinGrammar(cmdName, ownSpec); if (!borrowed && cmdName in CWD_DEFAULT_RAW) { const operand = defaultCwdOperand(parts, cmdName, registry, session.cwd, stdin); if (operand !== null) { // Where GNU's implied `.` sits: after the pattern for grep/rg // (the first positional is the pattern), right after the command // name for find/tree/du/ls (find's expression tokens must stay // behind the path). parts = cmdName === 'grep' || cmdName === 'rg' ? [...parts, operand] : [head, operand, ...parts.slice(1)]; } } const pathScopes = []; for (let i = 1; i < parts.length; i++) { const p = parts[i]; if (p instanceof PathSpec) pathScopes.push(p); } const rawArgv = parts.slice(1).map((p) => (typeof p === 'string' ? p : p.virtual)); // --help and --version answer from the package, never from a backend, so // they are served before mount permission checks and cross-mount routing: // otherwise `rm --version /ro/x` hits the read-only refusal, // `cat --version /ram/a /disk/b` parses against the shared spec, which // carries no injected --version, and fails as an unknown option, and // `mv --help /ram/a /disk/b` reaches the cross-mount relay, which bypasses // the registered wrapper that answers help and MOVED THE FILE instead of // printing the page. const cmdMount = ownMount ?? registry.mountForCommand(cmdName); const cmdSpec = cmdMount?.specFor(cmdName) ?? null; const standardOut = standardRequest(cmdName, cmdSpec, rawArgv); const builtinSpec = SPECS[cmdName]; if (standardOut !== null && STANDARD_AFTER_SCAN.has(cmdName) && isBuiltinGrammar(cmdName, cmdSpec) && builtinSpec !== undefined) { const refused = valueRefusal(cmdName, parseFlags(parts.slice(1), registeredSpec(cmdName, builtinSpec), cmdName, session.cwd) .flagKwargs); if (refused !== null) { return [ null, refused, new ExecutionNode({ command: cmdStr, exitCode: refused.exitCode, stderr: await materialize(refused.stderr), }), ]; } } if (standardOut !== null) { return [standardOut, new IOResult(), new ExecutionNode({ command: cmdStr, exitCode: 0 })]; } let prepared = null; if (!borrowed && PROGRAM_FILE_COMMANDS.has(cmdName)) { const programSpec = SPECS[cmdName]; if (programSpec !== undefined) { const candidate = parseFlags(parts.slice(1), registeredSpec(cmdName, programSpec), cmdName, session.cwd); if (programFiles(cmdName, candidate.flagKwargs).length > 0) { prepared = candidate; const refusal = optionError(cmdName, prepared); if (refusal !== null) { const [msg, code] = refusal; return [ null, new IOResult({ exitCode: code, stderr: msg }), new ExecutionNode({ command: cmdStr, exitCode: code, stderr: msg }), ]; } const [texts, flags, remaining, error] = await prepareProgram(cmdName, prepared.texts, prepared.flagKwargs, stdin, dispatch, prepared.paths); if (error !== null) { return [ null, error, new ExecutionNode({ command: cmdStr, exitCode: error.exitCode, stderr: await materialize(error.stderr), }), ]; } stdin = remaining; prepared = { ...prepared, texts, flagKwargs: flags }; pathScopes.splice(0, pathScopes.length, ...prepared.paths); } } } // Path-valued flags (e.g. shuf --output=/dst/out) own a mount just like // positional operands, so they join routing and mount validation instead of // being dropped whenever a positional path is also present. // The empty name joins onto the working directory in `virtual` but names // no path there, so it routes nowhere: the line runs where its other // operands (or the cwd) put it, and that run's op guards refuse it. A line // is not cross-mount because one of its words is empty. // A prepared program line already holds its positional operands. let routingScopes = borrowed ? operands : []; let flagScopes = []; if (!borrowed && !optionLoopExits(cmdName, cmdMount?.specFor(cmdName) ?? null, rawArgv, session.cwd)) { const routed = routableScopes(cmdName, prepared !== null ? pathScopes : routedOperands(cmdName, rawArgv, session.cwd, parts.slice(1), pathScopes)); flagScopes = pathFlagScopes(cmdName, rawArgv, session.cwd); routingScopes = mergeScopes(routed, flagScopes).filter((s) => s.walkError !== 'ENOENT'); } let findExpr = null; if (!borrowed && cmdName === 'find') { try { const parsedFind = parseFind(spelledWords(parts.slice(1))); // Every -newer reference is statted through the dispatcher here, // once, before the expression is bound; a date GNU refuses stops the // stats at the references typed before it. const badDate = parsedFind.dateRefusal(); const refs = badDate === null ? parsedFind.newer : parsedFind.newer.slice(0, badDate[0]); let newerTimes = []; if (refs.length > 0) { let refErr; [newerTimes, refErr] = await resolveNewerRefs(refs, registry, session.cwd, (path) => pathStat(dispatch, path), namespace ?? null, lineFacts(cmdName, parts, session.cwd).follows !== Follow.NONE); if (refErr !== null) { return [ null, new IOResult({ exitCode: 1, stderr: refErr }), new ExecutionNode({ command: cmdStr, stderr: refErr, exitCode: 1 }), ]; } } if (badDate !== null) throw badDate[1]; findExpr = parsedFind.bind(newerTimes); } catch (err) { if (err instanceof UsageError) { const errBytes = encodeText(`${err.message}\n`); return [ null, new IOResult({ exitCode: err.exitCode, stderr: errBytes }), new ExecutionNode({ command: cmdStr, stderr: errBytes, exitCode: err.exitCode }), ]; } throw err; } } // Path-valued flags count: `cp -t /other/mount/dir src` spans mounts // exactly like a positional destination would. if (!borrowed && isCrossMount(cmdName, operandMounts(registry, cmdName, routingScopes), flagScopes)) { // Parse against the shared spec so flags and text operands do not // depend on the source mount: raw argv would hand flag tokens ("-c") // to the generic as the search pattern. The bound single-mount runner // lets the strategy runners execute each operand natively on its // owning mount. // Registered, not declared: the registry injects --help/--version into // every spec, so parsing the declaration here made the same word mean // two things by mount count -- `cat --vers=x /ram/a` was // `option '--version' doesn't allow an argument` and the two-mount line // was `unrecognized option '--vers=x'`. const sharedSpec = SPECS[cmdName] ?? cmdMount?.specFor(cmdName) ?? undefined; const csParsed = prepared ?? parseFlags(parts.slice(1), sharedSpec !== undefined ? registeredSpec(cmdName, sharedSpec) : null, cmdName, session.cwd); let csFlags = csParsed.flagKwargs; const csTexts = findExpr !== null ? rawArgv.slice(findExpr.start) : csParsed.texts; const csRefusal = optionError(cmdName, csParsed); if (csRefusal !== null) { const [msg, code] = csRefusal; return [ null, new IOResult({ exitCode: code, stderr: msg }), new ExecutionNode({ command: cmdStr, exitCode: code, stderr: msg }), ]; } // A path option's value (sort -o, cp -t, csplit -f) routes to its owning // mount but is not an input. Parsed operands preserve aliases, order, and // repeated path values. find's expression is not the spec's grammar, so // its start points are the words classified as paths. let csScopes = findExpr !== null ? pathScopes : csParsed.paths; if (RELAY_COMMANDS.has(cmdName)) { // STREAM and FANOUT (and a custom command's reducer) run each operand // natively on its mount, which expands the operand's glob. RELAY sees every operand at once (wc's // layout, cp's sources), so its glob operands must expand here; an // unmatched glob stays the literal word, like bash. One operand at a // time, so join's option loop sees each match where its glob was typed. const groups = []; for (const scope of csScopes) { const expanded = await resolveGlobs([scope], registry, false, namespace === undefined ? null : namespace.sessionLinks(session), globOptions(session)); groups.push(expanded.filter((p) => typeof p !== 'string')); } csScopes = groups.flat(); csFlags = spreadOperands(csFlags, groups.map((group) => group.map((p) => p.rawPath))); } const runSingle = (name, ps, ts, fk, opts) => runOnMount(runCtx, name, ps, ts, fk, { ...(findExpr !== null ? { find: findExpr } : {}), ...opts, }); // A per-operand native run is single-mount by construction, so a // traversal operand holding nested mounts has to fan out inside it, // exactly as the same operand would on a line of its own. const csStat = (path) => pathStat(dispatch, path); const runOperand = runWithFanout(runSingle, registry, session.cwd, files, sessionView(session, registry.policies, context.frame.diagnostics), mergeSignals(signal, context.frame.abortSignal), dispatch, follow, findExpr ?? undefined, parts); const [csStdout0, csIo] = await handleCrossMount(cmdName, csScopes, csTexts, csFlags, dispatch, runOperand, stdin, makeStorageKey(registry), files, sessionView(session, registry.policies, context.frame.diagnostics), session.cwd, spelledWords(parts.slice(1)), aggregateFor(cmdName, operandMounts(registry, cmdName, csScopes)), makeCheckUnlink(registry, sessionAccess(session)), follow, registry.diff, registry.ids, mergeSignals(signal, context.frame.abortSignal)); const csExec = new ExecutionNode({ command: cmdStr, stderr: await csIo.materializeStderr(), exitCode: csIo.exitCode, }); let csStdout = csStdout0; if (findExpr !== null) { csStdout = await finishFind(csStdout, csIo, findExpr, registry, context, executeFn, files, csStat, dispatch, stdin, csScopes, mergeSignals(signal, context.frame.abortSignal)); csExec.exitCode = csIo.exitCode; csExec.stderr = await csIo.materializeStderr(); } if (csParsed.warnings.length > 0) { const csWarn = encodeText(csParsed.warnings.map((w) => `${cmdName}: ${w}\n`).join('')); const csExisting = await materialize(csIo.stderr); csIo.stderr = concat([csWarn, csExisting]); csExec.stderr = concat([csWarn, csExec.stderr]); } // The native sub-runs carry their own mount's scope; the cross-mount // command as a whole is bounded by the strictest cap across the // operand mounts, regardless of which sub-run merged last. const mounts = []; for (const s of pathScopes) { // a scope outside any mount contributes nothing here const m = registry.tryMountFor(s.virtual); if (m !== null) mounts.push(m); } csIo.producer = { command: cmdName, prefixes: mounts.map((m) => m.prefix), declared: null, }; csExec.paths = pathScopes; return [csStdout, csIo, csExec]; } // Path-flag targets count: a command bound to one mount cannot write its // output through another. A borrowed line runs on its predicted mount, so // every path its own spec reads, operand or option value, must lie there // too, or its handler reads another mount's path through its own. let spanScopes = routingScopes; const mountPrefixes = new Set(); if (borrowed && ownMount !== null) { const own = parseFlags(parts.slice(1), ownSpec, cmdName, session.cwd); spanScopes = [ ...own.paths, ...Object.values(own.flagKwargs) .flatMap((value) => (Array.isArray(value) ? value : [value])) .filter((item) => item instanceof PathSpec), ]; mountPrefixes.add(ownMount.prefix); } for (const s of spanScopes) { // a scope outside any mount contributes nothing here const m = registry.tryMountFor(s.virtual); if (m !== null) mountPrefixes.add(m.prefix); } if (mountPrefixes.size > 1) { const prefixesStr = [...mountPrefixes].sort(compareCodePoints).join(', '); const err = encodeText(`${cmdName}: paths span multiple mounts (${prefixesStr}), cross-mount not supported\n`); return [ null, new IOResult({ exitCode: 1, stderr: err }), new ExecutionNode({ command: cmdStr, exitCode: 1 }), ]; } let mount; try { mount = await registry.resolveMount(cmdName, routingScopes, session.cwd, rawArgv, sessionAccess(session)); } catch (err) { if (err instanceof MountCommandUnsupported || err instanceof MountCommandAmbiguous) { const errBytes = encodeText(`${err.message}\n`); return [ null, new IOResult({ exitCode: 1, stderr: errBytes }), new ExecutionNode({ command: cmdStr, stderr: errBytes, exitCode: 1 }), ]; } throw err; } if (mount === null) { const err = encodeText(`${cmdName}: command not found`); return [ null, new IOResult({ exitCode: NOT_FOUND_STATUS, stderr: err }), new ExecutionNode({ command: cmdStr, exitCode: NOT_FOUND_STATUS }), ]; } const selectedTree = mount.cliFor(cmdName); if (selectedTree !== null) return runTree(mount, selectedTree); const parsedLine = prepared ?? parseFlags(parts.slice(1), mount.specFor(cmdName), cmdName, session.cwd); const { paths: parsedPaths, flagKwargs, warnings: parseWarnings } = parsedLine; const textsRaw = parsedLine.texts; const refusal = optionError(cmdName, parsedLine); if (refusal !== null) { const [msg, code] = refusal; return [ null, new IOResult({ exitCode: code, stderr: msg }), new ExecutionNode({ command: cmdStr, exitCode: code, stderr: msg }), ]; } const texts = findExpr !== null ? rawArgv.slice(findExpr.start) : textsRaw; // The start points are the path words before the expression; the // spec's rest slot would otherwise read an -exec word as one. const paths = findExpr !== null ? findStartPoints(parts.slice(1), findExpr.start, mount.specFor(cmdName), session.cwd) : parsedPaths; if (findExpr !== null) { // `multiple: true` on find value-flags makes parseToKwargs emit arrays; // bespoke backend wrappers read these as scalars. Migrated backends read // the expression from `texts` and ignore flagKwargs. for (const [key, value] of Object.entries(flagKwargs)) { if (Array.isArray(value)) { const last = value.at(-1); if (last !== undefined) flagKwargs[key] = last; } } } const warnBytes = parseWarnings.length > 0 ? encodeText(parseWarnings.map((w) => `${cmdName}: ${w}\n`).join('')) : null; const singleStat = (path) => pathStat(dispatch, path); if (!borrowed && shouldFanOut(cmdName, paths, parts, session.cwd, registry)) { const [fanOut0, fanIo, fanNode] = await fanOutTraversal(cmdName, paths, texts, flagKwargs, registry, mount, session.cwd, cmdStr, stdin, files, sessionView(session, registry.policies, context.frame.diagnostics), mergeSignals(signal, context.frame.abortSignal), dispatch, (name, ps, ts, fk, opts) => runOnMount(runCtx, name, ps, ts, fk, { ...(findExpr !== null ? { find: findExpr } : {}), ...opts, argv: spelledWords(parts.slice(1)), }), follow, findExpr ?? undefined); let fanOut = fanOut0; if (findExpr !== null) { fanOut = await finishFind(fanOut, fanIo, findExpr, registry, context, executeFn, files, singleStat, dispatch, stdin, paths, mergeSignals(signal, context.frame.abortSignal)); fanNode.exitCode = fanIo.exitCode; fanNode.stderr = await materialize(fanIo.stderr); } if (warnBytes !== null) { const existing = await materialize(fanIo.stderr); fanIo.stderr = concat([warnBytes, existing]); fanNode.stderr = concat([warnBytes, fanNode.stderr]); } return [fanOut, fanIo, fanNode]; } const [rawStdout, io] = await runOnMount(runCtx, cmdName, paths, texts, flagKwargs, { stdin, mount, resolveHint: routingScopes[0] ?? null, argv: spelledWords(parts.slice(1)), ...(findExpr !== null ? { find: findExpr } : {}), }); let stdout = rawStdout; if (findExpr !== null) { stdout = await finishFind(stdout, io, findExpr, registry, context, executeFn, files, singleStat, dispatch, stdin, paths, mergeSignals(signal, context.frame.abortSignal)); } if (warnBytes !== null) { const existing = await materialize(io.stderr); io.stderr = concat([warnBytes, existing]); } return [stdout, io, await execNode(cmdStr, io, paths)]; } //# sourceMappingURL=command.js.map