@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
417 lines • 17.3 kB
JavaScript
// ========= 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