@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
416 lines • 20.1 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 { isFsError } from "../../errors/fs.js";
import { CommandTimeoutError } from "../../errors/types.js";
import { asyncChain } from "../../io/stream.js";
import { activeRecords } from "../../observe/context.js";
import { IOResult, materialize } from "../../io/types.js";
import { encodeText } from "../../shell/bytes.js";
import { Channel } from "../../shell/console/index.js";
import { Recorder } from "../../shell/descriptors.js";
import { DiscardSignal, ExitSignal } from "../../shell/errors.js";
import { getText, sameLine } from "../../shell/helpers.js";
import { pipelineTransparent } from "../../shell/node_kind.js";
import { PARSED_WORDS } from "../../shell/parse/constants.js";
import { NodeType as NT } from "../../shell/types.js";
import { concat } from "../../utils/bytes.js";
import { compareCodePoints } from "../../utils/sort.js";
import { expandingAliases } from "../executor/builtins/alias/alias.js";
import { divertStatement } from "../executor/builtins/exec/index.js";
import { carried, isUnwinding, LoopSignal } from "../executor/control.js";
import { handleBackground } from "../executor/jobs.js";
import { asWritten, errexitActs, failedRead, fd0Binding, land, landLate, recording, recordJobs, recordStatus, statementOutput, statementStdin, } from "../executor/statement.js";
import { errTrapArmed, runErrTrap, runExitTrap } from "../executor/traps.js";
import { ExecutionNode } from "../types.js";
import { isControlFlowError } from "../workspace/failure.js";
/** Forward ordinary output while retaining chunks needing descriptor routing. */
class StatementRecorder extends Recorder {
session;
sink;
constructor(session, sink) {
super();
this.session = session;
this.sink = sink;
}
async emit(channel, data) {
if (this.sink !== null &&
this.chunks.length === 0 &&
this.session.execStdout === null &&
this.session.execStderr === null)
await this.sink.emit(channel, data);
else
await super.emit(channel, data);
}
}
/**
* Execute program node (root / semicolon-separated).
*
* `dispatch` is the dispatcher, threaded so an active `exec` redirect can
* send each statement's output to its file; undefined (a nested loop that
* is not the program root) leaves output undiverted. `handed` and
* `decisions` are the line's hand-off and its ledger, for a background job
* to borrow. `sink` takes each statement's output as it finishes, in the
* order it was written, instead of the result. The outermost program of a
* session's line routes what a statement wrote to the session's terminal
* through a copy (`exec 3>&1`); a nested one (`eval`, `source`) leaves that
* to it. An `inline` program runs on its caller's frames (`eval`, `source`,
* an alias, `$( )`), so an `exit`, `return`, `break` or `continue` goes on
* into the caller, after what the program wrote; any other program is a
* shell of its own and ends there, running its EXIT action through
* `executeFn`. Either resumes at its next line after an error that
* discards one, unless it runs in a child shell. `set -n` stops the loop at
* the next statement, so a later `set +n` never runs. `set -v` echoes each
* input line once, as the first statement on it runs, from the line after
* the last one echoed, comments and blank lines included, so `set -v; echo
* a` echoes nothing. An ERROR node, the rest of a line the parser refused,
* is skipped.
*/
export async function executeProgram(recurse, node, context, stdin, callStack, jobTable, dispatch, handed = null, decisions = null, sink = null, inline = false, executeFn = null) {
const session = context.session;
// Every program loop is one parse, which is the unit bash's alias rule
// counts in: an alias defined on this parse and row is not expanded by
// a use on the same parse and row. Restored on the way out so a nested
// parse (`eval`, `source`, `bash -c`) does not leave its id behind.
session.parseSeq += 1;
const outerParse = [session.parseCurrent, session.parseRow];
session.parseCurrent = session.parseSeq;
session.parseRow = 0;
const root = !session.lineOpen;
session.lineOpen = true;
try {
return await runProgram(recurse, node, context, stdin, callStack, jobTable, dispatch, handed, decisions, sink, root ? session.terminal : null, inline, executeFn);
}
finally {
;
[session.parseCurrent, session.parseRow] = outerParse;
if (root) {
session.lineOpen = false;
session.errexitExiting = false;
}
}
}
async function runProgram(recurse, node, context, stdin, callStack, jobTable, dispatch, handed = null, decisions = null, sink = null, own = null, inline = false, executeFn = null) {
const session = context.session;
const children = node.children;
const allStdout = [];
let mergedIo = new IOResult();
let lastExec = new ExecutionNode({ command: '', exitCode: 0 });
const ran = [];
// The text starts at the first statement's row, after the blank rows an
// input read again keeps before it.
const firstRow = node.startPosition?.row ?? 0;
const sourceLines = [...Array(firstRow).fill(''), ...getText(node).split('\n')];
let echoedRow = firstRow - 1;
const bound = fd0Binding(session);
// bash reads its input a line at a time: lines before one it refuses
// run first, and a line after a change to what it parses with (an alias
// of a reserved word, `extglob`) is read with it.
const reader = (node.parent ?? null) === null ? executeFn : null;
const refused = reader !== null ? refusedRow(node) : null;
const parsed = reader !== null ? parseState(session) : null;
// A typed line bash's reader reads as several commands is recorded a
// command at a time, as its root reaches each.
const history = currentCaller().history;
const reading = history?.reads(node) === true ? history : null;
let lastRow = -1;
let i = 0;
while (i < children.length) {
const child = children[i];
if (child === undefined)
break;
const startRow = child.startPosition?.row ?? 0;
const endRow = child.endPosition?.row ?? startRow;
const reread = reader !== null &&
child.isNamed === true &&
child.type !== NT.COMMENT &&
((refused !== null && endRow >= refused) ||
(parsed !== null && lastRow >= 0 && startRow > lastRow && parseState(session) !== parsed));
if (!reread &&
(child.isNamed !== true || child.type === NT.COMMENT || child.type === NT.ERROR)) {
i += 1;
continue;
}
if (session.shellOptions.noexec === true)
break;
if (reading !== null)
await reading.reach(child, session, activeRecords() ?? []);
if (!reread && startRow > echoedRow) {
const first = echoedRow + 1;
const last = child.endPosition?.row ?? startRow;
if (session.shellOptions.verbose === true && last >= first) {
const text = sourceLines.slice(first, last + 1).join('\n');
mergedIo = await land([[Channel.STDERR, encodeText(`${text}\n`), false]], sink, allStdout, mergedIo);
}
echoedRow = last;
}
lastRow = endRow;
if (!reread && children[i + 1]?.type === NT.BACKGROUND) {
let launched;
try {
launched = await handleBackground(recurse, child, null, context, jobTable, stdin, callStack, handed, decisions);
}
catch (err) {
if (!(err instanceof ExitSignal))
throw err;
// A job the shell cannot fork ends the line, as a failed fork(2)
// ends bash's.
mergedIo = await mergedIo.merge(new IOResult({ exitCode: err.exitCode, stderr: err.stderr }));
mergedIo.exitCode = err.exitCode;
recordStatus(session, err.exitCode);
lastExec = new ExecutionNode({
command: child.text,
exitCode: err.exitCode,
stderr: err.stderr,
});
ran.push(lastExec);
break;
}
const [bgStdout, bgIo, bgExec] = launched;
lastExec = bgExec;
ran.push(lastExec);
// Launching a job is itself a statement: bash sets $? to 0
// (the launch status), so `false; cmd & echo $?` prints 0.
recordStatus(session, bgIo.exitCode);
if (bgStdout !== null)
allStdout.push(bgStdout);
mergedIo = await mergedIo.merge(bgIo);
i += 2;
continue;
}
const at = i;
i += 1;
const armed = errTrapArmed(session);
// Ordinary output progresses while the statement runs. Descriptor
// copies and exec diversions retain their order for routing below.
const recorder = new StatementRecorder(session, sink);
const leave = recordJobs(session, recorder);
let io;
try {
// `exec < file` feeds the shell's stdin: a later `read` or `while
// read` sees it, and each statement reads on from where the one
// before it stopped.
const childStdin = statementStdin(session, stdin, bound);
let s;
[s, io, lastExec] = await recording(recorder, () => reread
? readOn(reader, node, child, session, childStdin, recorder, callStack, reading)
: recurse(child, context, childStdin, callStack, { sink: recorder }));
ran.push(lastExec);
let stdout;
try {
stdout = await materialize(s);
}
catch (err) {
if (isControlFlowError(err) || err instanceof CommandTimeoutError)
throw err;
// Lazy reads can fail on the first pull (e.g. a backend size
// guard), which is the command's failure, not a crash.
if (isFsError(err))
await failedRead(io, err, lastExec);
else {
io.stderr = concat([
await materialize(io.stderr),
encodeText(`${err instanceof Error ? err.message : String(err)}\n`),
]);
io.exitCode = 1;
}
lastExec.exitCode = io.exitCode;
stdout = null;
}
// What was read again stamped its own statuses.
recordStatus(session, io.exitCode, reread || pipelineTransparent(child));
// An `exec` redirect sends the shell's own output to a file: every
// statement after the `exec` diverts here, so nothing bubbles to the
// terminal and stderr lands in its own target.
const written = await divertStatement(dispatch, session, await statementOutput(recorder, stdout, io, own, sink), io, child, lastExec.command ?? '');
mergedIo = await land(written, sink, allStdout, mergedIo);
mergedIo = await mergedIo.merge(io);
mergedIo = await land(await runErrTrap(executeFn ?? null, child, io.exitCode, session, armed && !reread, stdin, callStack, lastExec), sink, allStdout, mergedIo);
mergedIo = await landLate(recorder, leave, own, sink, allStdout, mergedIo);
}
catch (err) {
if (!isUnwinding(err))
throw err;
// What it wrote before it left; the ERR action answering it left
// after that was landed.
mergedIo = await land(await statementOutput(recorder, null, new IOResult(), own, sink), sink, allStdout, mergedIo);
mergedIo = await landLate(recorder, leave, own, sink, allStdout, mergedIo);
let resumes;
[resumes, mergedIo, lastExec] = await unwound(err, child, context, stdin, callStack, sink, inline, executeFn, allStdout, mergedIo);
ran.push(lastExec);
if (resumes && !reread) {
i = nextLine(node, children, at);
continue;
}
break;
}
finally {
leave();
}
if (errexitActs(child, io.exitCode, session)) {
if (!inline) {
mergedIo = await exitShell(executeFn, context, io.exitCode, stdin, callStack, allStdout, mergedIo);
}
break;
}
if (reread)
break;
}
if (reading !== null && i >= children.length)
reading.read();
// Statements run in turn are one `;` node, so the line's tree keeps every
// command's result; what a caller reads of the program's own node (its
// operands, whether it was refused or never opened) stays the last
// statement's.
if (ran.length > 1) {
lastExec = new ExecutionNode({
op: ';',
exitCode: mergedIo.exitCode,
children: ran,
paths: lastExec.paths,
refused: lastExec.refused,
unopened: lastExec.unopened,
});
}
const parts = allStdout.filter((part) => part !== null);
if (parts.length === 1 && parts[0] !== undefined) {
return [parts[0], mergedIo, lastExec];
}
const combined = parts.length > 0 ? asyncChain(parts) : null;
return [combined, mergedIo, lastExec];
}
/**
* The first statement after `children[i]` on a later line, where a
* discarded line resumes. The parse has joined continued lines and folded
* each heredoc body into its statement, so a newline between two statements
* is a line break. Mirrors Python's _next_line.
*/
function nextLine(node, children, i) {
for (let j = i + 1;; j++) {
const before = children[j - 1];
const after = children[j];
if (before === undefined || after === undefined || !sameLine(node, before, after))
return j;
}
}
/**
* Settle a signal that unwound out of a statement, or out of the ERR action
* that answered it. bash's DISCARD resumes the loop at the next line with
* `$?` at 1. An inline program carries anything else on into
* its caller, after what it wrote; any other program ends its shell there:
* `exit`, or an error bash treats as one. Mirrors Python's _unwound.
*/
async function unwound(err, child, context, stdin, callStack, sink, inline, executeFn, allStdout, mergedIo) {
const session = context.session;
if (err instanceof DiscardSignal &&
callStack?.subshell !== true &&
session.shellOptions.errexit !== true) {
mergedIo = await land(asWritten(err.stdout, err.stderr), sink, allStdout, mergedIo);
mergedIo.exitCode = err.exitCode;
recordStatus(session, err.exitCode);
return [
true,
mergedIo,
new ExecutionNode({ command: getText(child), exitCode: err.exitCode, stderr: err.stderr }),
];
}
if (err instanceof DiscardSignal) {
// A line loop a child shell runs ends it on a discard with the
// discard's own status: `( eval ': ${R:=x}' )` and `$( : ${R:=x} )`
// end with 1, where the `( )` around a bare `: ${R:=x}` ends with 2.
err.containedCode = err.exitCode;
}
if (inline) {
const parts = allStdout.filter((part) => part !== null);
throw await carried(err, parts.length > 0 ? asyncChain(parts) : null, mergedIo);
}
if (err.stdout !== null)
allStdout.push(err.stdout);
const looped = err instanceof LoopSignal;
const code = looped ? err.io.exitCode : err.exitCode;
mergedIo = await mergedIo.merge(new IOResult({ exitCode: code, stderr: looped ? err.io.stderr : err.stderr }));
mergedIo = await exitShell(executeFn, context, code, stdin, callStack, allStdout, mergedIo);
recordStatus(session, mergedIo.exitCode);
return [false, mergedIo, new ExecutionNode({ command: 'exit', exitCode: mergedIo.exitCode })];
}
/**
* End this shell with `code`, running its EXIT action after what it wrote
* (`allStdout`, extended in place), and return its result with the status
* the shell ends with.
*/
async function exitShell(executeFn, context, code, stdin, callStack, allStdout, mergedIo) {
const session = context.session;
const cleanup = await runExitTrap(executeFn, session, code, stdin, callStack);
if (cleanup === null) {
mergedIo.exitCode = code;
return mergedIo;
}
allStdout.push(cleanup.stdout);
const merged = await mergedIo.merge(new IOResult({ stderr: cleanup.stderr }));
merged.exitCode = cleanup.exitCode;
return merged;
}
/**
* The row of the first line a parsed input's refusal belongs to, when lines
* bash reads and runs come before it: bash reads its input a line at a
* time, so those run first and the rest is read again from that row
* (`readOn`). Null when the input is refused from its first line, or not
* at all. Mirrors Python's refused_row.
*/
export function refusedRow(node) {
const statements = node.children.filter((c) => c.isNamed === true && c.type !== NT.COMMENT);
const last = statements[statements.length - 1];
if (last?.type !== NT.ERROR)
return null;
const refused = last.startPosition?.row ?? 0;
const first = statements.find((c) => (c.endPosition?.row ?? 0) >= refused);
const row = first?.startPosition?.row ?? refused;
if (!statements.some((c) => (c.endPosition?.row ?? 0) < row))
return null;
return row;
}
/**
* What a later line's parse depends on: the aliases expanding now of the
* words the parser reads as syntax, and `extglob`. Any other alias expands
* as its command runs. Mirrors Python's _parse_state.
*/
function parseState(session) {
const names = [...expandingAliases(session)].filter((name) => PARSED_WORDS.has(name));
return JSON.stringify([names.sort(compareCodePoints), session.shopts.extglob ?? false]);
}
/**
* Read the input again from `child` on, as bash reads its next line: with
* the aliases as they are now, and refused there if bash refuses it. The
* rows before it stay as blank lines, so its diagnostics and `$LINENO`
* keep their numbers. Mirrors Python's _read_on.
*/
async function readOn(executeFn, node, child, session, stdin, sink, callStack, reading) {
const original = node.sourceText ?? node.text;
const at = node.offsets?.[child.startIndex ?? 0] ?? 0;
const rest = original.slice(at);
const rows = original.slice(0, at).split('\n').length - 1;
const run = () => executeFn('\n'.repeat(rows) + rest, {
sessionId: session.sessionId,
stdin,
...(sink === null ? {} : { sink }),
...(callStack === null ? {} : { callStack }),
});
// The root the rest is read as takes the line's history over through
// this call alone, never a job's root that runs meanwhile.
const io = reading === null
? await run()
: await runWithCaller({ ...currentCaller(), history: reading.readOn(at - rows) }, run);
return [io.stdout, io, new ExecutionNode({ command: rest, exitCode: io.exitCode })];
}
//# sourceMappingURL=program.js.map