UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

232 lines • 10.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 { UsageError } from "../../../commands/errors.js"; import { readFailExitCode } from "../../../commands/spec/usage.js"; import { sessionAccess } from "../../../context/session_context.js"; import { formatFsError } from "../../../errors/render.js"; import { CommandTimeoutError } from "../../../errors/types.js"; import { PipeClosed } from "../../../io/errors.js"; import { IOResult } from "../../../io/types.js"; import { WorkspaceRuntime } from "../../../runtime/workspace.js"; import { encodeText } from "../../../shell/bytes.js"; import { CANNOT_EXECUTE_STATUS, NOT_FOUND_STATUS } from "../../../shell/constants.js"; import { PathSpec } from "../../../types.js"; import { makeAbortError, mergeSignals } from "../../../utils/abort.js"; import { Files } from "../../files.js"; import { MountCommandAmbiguous } from "../../mount/errors.js"; import { MountCommandUnsupported } from "../../mount/registry.js"; import { envSnapshot, sessionView } from "../../session/state.js"; import { ExecutionNode } from "../../types.js"; import { parseFlags } from "./flags.js"; /** * The entry point a command handler runs a nested line through (`opts.shell`): * the line runs in the calling command's own session, under its signal, * reading the input it is handed. */ export function nestedShell(executeFn, session, signal) { return (line, stdin = null) => executeFn(line, { sessionId: session.sessionId, session, stdin, ...(signal !== undefined ? { signal } : {}), }); } /** * find's start points: the path operands typed before its expression. * The expression's start is mirage-rs's, so a word inside it (an `-exec` * command word, a `-newer` reference) is never a start point even when * the rest slot's PATH kind would have read it as one. Only the head is * parsed against the spec, so what it yields as path operands is exactly * the start points. */ export function findStartPoints(argv, start, spec, cwd) { const head = argv.slice(0, start); return parseFlags(head, spec, 'find', cwd).paths; } /** * Build the recorded execution node, materializing any streamed stderr. * * The node is a recorded artifact, so the live lazy stderr is materialized * to concrete bytes here, and the bytes stay on `io` so the caller still * reads them. A result whose fields fill in as its output drains (a * runtime's run) brings the node up to date once they have. Mirrors * Python's `exec_node`. * * @param command original command text for the record. * @param io the command's result, whose stderr and status the node carries. * @param paths the classified path operands. */ export async function execNode(command, io, paths = []) { const node = new ExecutionNode({ command, stderr: await io.materializeStderr(), exitCode: io.exitCode, paths, }); const state = io.output; if (state !== null && !state.settled) { state.callbacks.push(() => { settled(node, io); }); } return node; } /** Record what a result settled as its output drained. Mirrors Python's `_settled`. */ function settled(node, io) { node.exitCode = io.exitCode; if (io.stderr instanceof Uint8Array) node.stderr = io.stderr; } /** The 126 result for a command no runtime accepted. */ export function admissionDenial(cmdName) { const msg = `${cmdName}: no runtime accepted this line\n`; return new IOResult({ exitCode: CANNOT_EXECUTE_STATUS, stderr: encodeText(msg) }); } /** * Resolve a command against the line's routing decision: its binding, * or the decision's fallback when no entry captures it. A resolved * WorkspaceRuntime means the executor serves the command itself (the * workspace runtime has no interpreter entry point); null means no runtime * accepted it: exit 126, "no runtime accepted this line", like a shell * refusing to exec. */ function lineRuntimeFor(cmdName, routingDecision) { const runtime = Object.hasOwn(routingDecision.bindings, cmdName) ? routingDecision.bindings[cmdName] : routingDecision.fallback; if (runtime === null || runtime === undefined) return [undefined, admissionDenial(cmdName)]; if (runtime instanceof WorkspaceRuntime) return [undefined, null]; return [runtime, 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. function scalarFindFlags(flagKwargs) { const out = { ...flagKwargs }; for (const [key, value] of Object.entries(out)) { if (Array.isArray(value)) { const last = value.at(-1); if (last !== undefined) out[key] = last; } } return out; } // Run one already-parsed command on the mount that owns its paths. The shared // single-mount execution tail: mount resolution, session-mode checks, runCommand, // filesystem-error formatting, ls/find post-processing, and read/write key // prefixing. handleCommand uses it for the normal path, and passes it (bound) // to the cross-mount runners so each operand executes natively on its owning // mount. `resolveHint` names the path whose mount runs the command, ahead of // the first of `paths`: a stream command in stdin mode has none, and awk over // operands on several mounts runs where its first file lives. A pre-resolved // `mount` skips resolution and session-mode checks, which the caller already // performed. export async function runOnMount(ctx, cmdName, paths, texts, flagKwargs, opts = {}) { const { registry, context, dispatch, namespace } = ctx; const session = context.session; const hint = opts.resolveHint ?? null; let mount = opts.mount ?? null; if (mount === null) { const resolvePaths = hint !== null ? [hint] : paths; try { mount = await registry.resolveMount(cmdName, resolvePaths, session.cwd, opts.argv, 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 })]; } throw err; } if (mount === null) { const errBytes = encodeText(`${cmdName}: command not found`); return [null, new IOResult({ exitCode: NOT_FOUND_STATUS, stderr: errBytes })]; } } let flags = flagKwargs; if (cmdName === 'find') flags = scalarFindFlags(flags); const routing = context.frame.routing; if (routing === undefined) throw new Error(`${cmdName}: run outside a routed line`); const [lineRuntime, denial] = lineRuntimeFor(cmdName, routing); if (denial !== null) return [null, denial]; const signal = mergeSignals(mergeSignals(ctx.signal, ctx.context.frame.abortSignal), opts.signal); // A leaf that resumes here after the caller aborted must not reach a // mount handler: eager write handlers do not read the signal, and a // cancelled `rm` must not run. if (signal?.aborted === true) throw makeAbortError(signal); const access = sessionAccess(session); try { return await mount.runCommand(cmdName, paths, texts, flags, { admission: ctx.admission, ...(registry.diff === undefined ? {} : { diff: registry.diff }), ids: registry.ids, stdin: opts.stdin ?? null, cwd: PathSpec.fromStrPath(session.cwd), vfs: new Files(dispatch, namespace === undefined ? null : namespace.readers(() => session.visibility), null, { follow: ctx.follow ?? null, ledger: false }), env: envSnapshot(session), session: sessionView(session, registry.policies, context.frame.diagnostics), ...(registry.processView === undefined ? {} : { processes: registry.processView(session) }), runtime: { execAllowed: registry.isExecAllowed(access), execPathAllowed: (virtual) => registry.execAllowedAt(virtual, access), ...(lineRuntime !== undefined ? { bound: lineRuntime, settle: () => registry.settle(lineRuntime) } : {}), }, ...(signal !== undefined ? { signal } : {}), ...(ctx.executeFn !== undefined ? { shell: nestedShell(ctx.executeFn, session, signal) } : {}), ...(opts.argv !== undefined ? { argv: opts.argv } : {}), ...(opts.find !== undefined ? { find: opts.find } : {}), }); } catch (err) { // Command-owned usage errors (extra operands, missing patterns) become // this command's IOResult so the rest of the line keeps running, like a // real shell (#452). if (err instanceof UsageError) { return [ null, new IOResult({ exitCode: err.exitCode, stderr: encodeText(`${err.message}\n`), }), ]; } // A limit timeout is not a filesystem failure: let it reach the // workspace-level handler that answers with exit 124. A write into a // pipe whose reader has gone is SIGPIPE, which the executor answers for // the writer. if (err instanceof CommandTimeoutError || err instanceof PipeClosed || (err instanceof Error && err.name === 'AbortError')) throw err; return [ null, new IOResult({ exitCode: readFailExitCode(cmdName, err), stderr: formatFsError(cmdName, err, paths), }), ]; } } //# sourceMappingURL=run.js.map