UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

417 lines • 17.3 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 { currentCaller, runWithCaller } from "../../context/session_context.js"; import { spooledPair } from "../../io/spool.js"; import { chunks } from "../../io/cooperative.js"; import { asyncChain, discardStreams } from "../../io/stream.js"; import { readFailExitCode } from "../../commands/spec/usage.js"; import { IOResult } from "../../io/types.js"; import { materialize } from "../../io/types.js"; import { PipeClosed } from "../../io/errors.js"; import { formatFsError } from "../../errors/render.js"; import { applyBarrier, BarrierPolicy } from "../../shell/barrier.js"; import { Inherited, deliver, unreadableStdin, } from "../../shell/descriptors.js"; import { Channel } from "../../shell/console/index.js"; import { concat } from "../../utils/bytes.js"; import { pipelineTransparent } from "../../shell/node_kind.js"; import { getNegatedCommand, getParts, getRedirects } from "../../shell/helpers.js"; import { literalText } from "../../shell/parse/names.js"; import { BASH_BUILTINS } from "../lookup/constants.js"; import { ERREXIT_EXEMPT_TYPES } from "../../shell/constants.js"; import { NodeType as NT } from "../../shell/types.js"; import { abortedLine, lineStatusWriter } from "../abort.js"; import { makeAbortError } from "../../utils/abort.js"; /** * Run a test, the left of `&&`/`||` or a negated command where bash ignores * `set -e`: nothing it runs exits for a failure, a function body or a * subshell included. Mirrors Python's ignoring_errexit. */ export async function ignoringErrexit(session, fn) { const saved = session.errexitIgnored; session.errexitIgnored = true; try { return await fn(); } finally { session.errexitIgnored = saved; } } /** Whether `set -e` ends the shell after this statement, marking the shell * as ending when it does. */ export function errexitActs(node, status, session) { const acts = status !== 0 && session.shellOptions.errexit === true && !ERREXIT_EXEMPT_TYPES.has(node.type) && !session.errexitImmune && !session.errexitIgnored; if (acts) session.errexitExiting = true; return acts; } /** * Record a finished statement's exit status: `$?` and `${PIPESTATUS[@]}` * together. * * The one function every status write goes through, so the two can never * disagree. `handlePipe` parks its per-segment statuses on the session, * and the boundary that closes the pipeline claims them here; a boundary * with nothing parked stamps its own one-element status, which is what a * simple command, a function call or a subshell leaves in bash. A * *transparent* statement (a group, a loop, a negation, a redirected * pipeline: see `pipelineTransparent`) claims what was parked but never * overwrites, because bash reports the last pipeline that ran *inside* it * (`{ false | true; }` keeps `1 0`). */ export function recordStatus(session, code, transparent = false) { // A statement that settles after the caller was released is an orphan. // Its status is nobody's `$?`, and the throw ends the loop that would // otherwise run the next statement on a shell nobody is waiting on. const lineAbort = abortedLine(session); if (lineAbort !== undefined) throw makeAbortError(lineAbort); // Whose status this is, so an aborted line puts back only what it // overwrote and never a concurrent line's finished result. session.statusWriter = lineStatusWriter(session); session.lastExitCode = code; const pending = session.pipeStatusPending; session.pipeStatusPending = null; if (pending !== null) session.pipeStatus = pending; else if (!transparent) session.pipeStatus = [code]; } /** Capture `$?` and `${PIPESTATUS[@]}` before a line runs. */ export function snapshotStatus(session) { return { lastExitCode: session.lastExitCode, pipeStatus: session.pipeStatus, pipeStatusPending: session.pipeStatusPending, }; } /** * Put back the status a line found, for a line the caller aborted. * Statements inside the line may already have stamped their own status * before the abort landed, and an aborted invocation is the caller's * outcome, not the shell's. * * Only what this line overwrote, though. Two `execute()` calls can * share a session, and a snapshot taken before a concurrent line * finished is older than that line's result: putting it back would * resurrect a value the shell had already moved past. So the restore * happens only while the last stamp is still this line's. When nobody * has stamped since the snapshot the status already equals it and * declining is the same thing; when someone else did, declining is the * point. */ export function restoreStatus(session, snapshot, writer) { if (session.statusWriter !== writer) return; session.lastExitCode = snapshot.lastExitCode; session.pipeStatus = snapshot.pipeStatus; session.pipeStatusPending = snapshot.pipeStatusPending; } /** * Park the status just recorded again, for the boundary that closes the * enclosing statement to claim rather than stamp over. A conditional * list that short-circuits has closed its left pipeline and runs * nothing else, and bash reports the list as that pipeline: * `true | false && true` keeps `0 1`. The list is not a pipeline of its * own, so without this its boundary would stamp the aggregate `1`. */ export function carryStatus(session) { session.pipeStatusPending = session.pipeStatus; } /** * Whether bash forks to run a command: a subshell or a program, whose * redirects expand in the child and whose closed pipe ends it alone; a * builtin, a function or a compound command runs in the shell itself. * `command -v` is the builtin, `command X` masks functions. The expanded * `words` decide once there are any; before, a name only an expansion spells * (`$cmd`) counts as a program. Mirrors Python's forks. */ export function forks(command, session, words = null) { if (command?.type !== NT.COMMAND) return command?.type === NT.SUBSHELL; let rest = words !== null ? [...words] : getParts(command) .filter((part) => part.type !== NT.VARIABLE_ASSIGNMENT) .map(spelled); let functions = true; while (rest[0] === 'command') { rest = rest.slice(1); functions = false; while (rest[0] !== undefined && (rest[0] ?? '').startsWith('-')) { const option = rest[0] ?? ''; rest = rest.slice(1); if (option === '--') break; if (option.includes('v') || option.includes('V')) return false; } } const name = rest[0]; if (name === undefined) return false; return (name === null || (!BASH_BUILTINS.has(name) && !(functions && session.functions[name] !== undefined))); } /** A word's text when it is literal, null when an expansion spells it. */ function spelled(word) { const inner = word.type === NT.COMMAND_NAME && word.namedChildren[0] !== undefined ? word.namedChildren[0] : word; return literalText(inner); } /** * Whether what a statement wrote came from a child bash forks for it, so a * closed pipe ends that writer alone: a program, a subshell or a pipeline, * redirected or negated too. A command is judged by the producer its output * names, what its head ran as (`$cmd`). Mirrors Python's writer_forks. */ export function writerForks(statement, session, io) { if (statement === null) return false; if (statement.type === NT.REDIRECTED_STATEMENT) return writerForks(getRedirects(statement)[0], session, io); if (statement.type === NT.NEGATED_COMMAND) return writerForks(getNegatedCommand(statement), session, io); if (statement.type === NT.PIPELINE) return true; const producer = io.producer?.command ?? null; return forks(statement, session, producer === null ? null : [producer]); } /** * Deliver a writer's own answer to its stdout's reader going away (`tee -p`) * in place of SIGPIPE, if it has one. Mirrors Python's answer_closed. */ export async function answerClosed(io, emit) { const closed = io.closedStdout; if (closed === null) return false; if (closed.stderr.byteLength > 0) await emit(Channel.STDERR, closed.stderr); if (closed.exitCode !== null) io.exitCode = closed.exitCode; return true; } /** * Finalize a completed statement and seed $? for the next one. * * Every statement boundary must do the same dance: apply a VALUE * barrier so lazily finalized exit codes (grep's) are * concrete, then record the status the next statement's $? expands * to. Statement-list loops (program, subshell, brace group, if/loop/ * case bodies, function bodies, && / || / ; lists) call this instead * of hand-rolling the triple, so a new construct cannot forget it. The * node, when the caller has it, decides whether the statement stamps * `PIPESTATUS` itself; without one it stamps. */ export async function finishStatement(stdout, io, session, node = null, execNode = null) { // The barrier is the first pull of a lazy stream, so a read that fails // there (`cat` on a closed stdin, a size guard) is the statement's // failure, in the command's own words, rather than an exception that // escapes the body and kills the line; the program loop drains the // same way. let result; try { result = await applyBarrier(stdout, io, BarrierPolicy.VALUE); } catch (err) { await failedRead(io, err, execNode); result = null; } recordStatus(session, io.exitCode, node !== null && pipelineTransparent(node)); return result; } /** * A read the statement's output stream failed, as its own failure: `cat: -: * Bad file descriptor` on its stderr and status. Anything but a filesystem * error is rethrown. Mirrors Python's failed_read. */ export async function failedRead(io, err, execNode) { if (!(err instanceof Error) || typeof err.code !== 'string') throw err; const cmdName = execNode?.command?.split(' ')[0] ?? ''; io.stderr = concat([ await materialize(io.stderr), formatFsError(cmdName, err, execNode?.paths ?? []), ]); io.exitCode = readFailExitCode(cmdName, err); } /** * Exit status of an assignment-only statement. * * Bash: an assignment statement exits 0 unless expanding it ran * command substitutions, in which case the status of the last * substitution performed becomes the statement's own. */ /** * What the shell's fd 0 is bound to: the descriptor `exec <` opened, and * whether an `exec` left it unreadable (`exec <&-`). A construct takes * this as it starts, so `statementStdin` can tell an `exec` made inside * it from one made before it. */ export function fd0Binding(session) { return [session.execStdin, session.execStdinUnreadable]; } /** * The stdin one statement of a construct reads. * * bash's fd 0 is one descriptor, so an `exec <` replaces whatever a * construct was handed: `printf z | { exec < f; read a; }` reads `f`. * Every statement-list loop asks this before each statement. The * construct's own stdin stands while fd 0 is still what it was when the * construct started (`bound`); a construct handed none reads fd 0, the * descriptor `exec <` opened or EBADF after `exec <&-`. */ export function statementStdin(session, stdin, bound) { if (stdin !== null && session.execStdin === bound[0] && session.execStdinUnreadable === bound[1]) { return stdin; } if (session.execStdinUnreadable) return unreadableStdin(); return session.execStdin ?? stdin; } export function assignmentStatus(frame, seqBefore) { if (frame.cmdsubSeq !== seqBefore) return frame.cmdsubStatus; return 0; } /** * Output written as it is, stdout then stderr, none of it through a copy of * the terminal. Mirrors Python's as_written. */ export function asWritten(stdout, stderr) { const written = []; if (stdout !== null && stdout.byteLength > 0) written.push([Channel.STDOUT, stdout, false]); if (stderr !== null && stderr.byteLength > 0) written.push([Channel.STDERR, stderr, false]); return written; } /** * Run a statement into `recorder`: what it writes to an enclosing level's * stream lands among what it writes. Mirrors Python's recording. */ export async function recording(recorder, run) { return runWithCaller({ ...currentCaller(), enclosing: recorder }, run); } /** * Send what the jobs this shell started write into `recorder`, among what its * statement writes, until the returned call ends it. The recording lasts past * the statement, until `landLate` has landed what was recorded, so a job * writing in between cannot reach the shell's own output ahead of the * statement. Ended with `after`, what jobs write next waits for it. Mirrors * Python's record_jobs. */ export function recordJobs(session, recorder) { const jobs = session.jobOutput ?? session.tty.jobs; const leave = jobs.record(recorder); return (after) => { leave(); if (after !== undefined) jobs.holdUntil(after); }; } /** * Land what jobs wrote into `recorder` once the statement's output was taken, * and end the recording (`recordJobs`). What jobs write from the end on waits * until what was recorded has landed, so nothing written later overtakes it, * and nothing is left in `recorder` unread. Mirrors Python's land_late. */ export async function landLate(recorder, leave, own, sink, allStdout, mergedIo) { let landed = () => undefined; leave(new Promise((resolve) => { landed = resolve; })); try { while (recorder.busy) mergedIo = await land(await statementOutput(recorder, null, new IOResult(), own, sink), sink, allStdout, mergedIo); } finally { landed(); } return mergedIo; } /** * What a statement wrote that stays with the shell running it, taken off * `recorder`. Bytes written to the shell's terminal through a copy (`exec * 3>&1`, whose owner is `own`) stay, flagged; bytes written to an enclosing * level's stream go on there. What the statement returned rather than wrote * comes last, its stderr taken off `io`. Mirrors Python's statement_output. */ export async function statementOutput(recorder, stdout, io, own, sink) { const written = []; for (const [key, data] of await recorder.take()) { if (!(key instanceof Inherited)) written.push([key, data, false]); else if (key.owner === own || !(await deliver(sink, key, data, currentCaller().enclosing))) written.push([key.channel, data, true]); } let out; let err; try { ; [out, err] = await spooledPair(stdout, io.stderr); } catch (error) { await discardStreams(...written.map(([, data]) => data)); throw error; } io.stderr = null; if (out !== null) written.push([Channel.STDOUT, out, false]); if (err !== null) written.push([Channel.STDERR, err, false]); return written; } /** * Put a statement's output where its shell's goes: the sink, in order, or the * stdout and stderr the shell returns. The result keeps its status. Mirrors * Python's land. */ export async function land(written, sink, allStdout, mergedIo) { if (sink !== null) { for (const [index, [channel, data]] of written.entries()) { try { for await (const part of chunks(data)) await sink.emit(channel, part); } catch (err) { if (!(err instanceof PipeClosed)) throw err; // SIGPIPE keeps what the writer already said on stderr. for (const [later, more] of written.slice(index + 1)) { if (later !== Channel.STDERR) await discardStreams(more); else for await (const part of chunks(more)) await sink.emit(later, part); } throw err; } } return mergedIo; } for (const item of written.filter(([c]) => c === Channel.STDOUT).map(([, d]) => d)) allStdout.push(item); const errors = written.filter(([c]) => c === Channel.STDERR).map(([, d]) => d); if (errors.length > 0) mergedIo.stderr = asyncChain([mergedIo.stderr, ...errors]); return mergedIo; } //# sourceMappingURL=statement.js.map