@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
586 lines • 25.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 { atoi } from "../../commands/builtin/utils/strtol.js";
import { lineBuffer } from "../../io/async_line_iterator.js";
import { asyncChain } from "../../io/stream.js";
import { IOResult, materialize } from "../../io/types.js";
import { PolicyDenied } from "../../policy/errors.js";
import {} from "../../policy/index.js";
import { encodeText } from "../../shell/bytes.js";
import { Channel } from "../../shell/console/index.js";
import { ArithError, ExitSignal, ReadonlyVariableError, ReturnSignal } from "../../shell/errors.js";
import { NodeType as NT } from "../../shell/types.js";
import { wordText } from "../../types.js";
import { concat } from "../../utils/bytes.js";
import { fnmatch } from "../../utils/fnmatch.js";
import { sessionView, visibleEnv } from "../session/state.js";
import { ExecutionNode } from "../types.js";
import { readReply } from "./builtins/read/index.js";
import { runStatement } from "./jobs.js";
import { errexitActs, fd0Binding, finishStatement, ignoringErrexit, land, recordStatus, } from "./statement.js";
import { errTrapArmed, runErrTrap, runReturnTrap } from "./traps.js";
const MAX_WHILE = 10_000;
export class LoopSignal extends Error {
stdout;
io;
levels;
constructor(stdout = null, io = new IOResult(), levels = 1) {
super(new.target === BreakSignal ? 'break' : 'continue');
this.stdout = stdout;
this.io = io;
this.levels = levels;
this.name = new.target.name;
}
}
export class BreakSignal extends LoopSignal {
}
export class ContinueSignal extends LoopSignal {
}
/**
* Execute a list of statements in order: a group, a loop or `if` body, a
* `case` arm, a function body.
*
* A statement ending in `&` is launched as a job through `runStatement`
* rather than run inline; `jobTable` is the job plane it needs. The ERR
* action answers a failing statement, its output landed
* through `sink` when the body writes to one, and `set -e` stops the list.
* `bound` is `fd0Binding` as the construct running the list started, so an
* `exec <&-` in a loop body reaches the next test. A comment leaves `$?` as
* it was, and a `break` or `continue` records its own status as it leaves
* (bash leaves `${PIPESTATUS[@]}` at `0`).
*/
export async function executeBody(executeNode, body, context, stdin, callStack, jobTable, handed, decisions, executeFn = null, sink = null, bound = fd0Binding(context.session)) {
const session = context.session;
const allStdout = [];
let mergedIo = new IOResult();
let lastExec = new ExecutionNode({ command: '', exitCode: 0 });
for (const cmd of body) {
if (cmd.type === NT.COMMENT)
continue;
const armed = errTrapArmed(session);
try {
const [stdout, io, execNode] = await runStatement(executeNode, cmd, context, stdin, bound, callStack, jobTable, handed, decisions);
lastExec = execNode;
allStdout.push(await finishStatement(stdout, io, session, cmd));
mergedIo = await mergedIo.merge(io);
mergedIo = await land(await runErrTrap(executeFn, cmd, io.exitCode, session, armed, stdin, callStack, execNode), sink, allStdout, mergedIo);
if (errexitActs(cmd, io.exitCode, session))
break;
}
catch (sig) {
if (!isUnwinding(sig))
throw sig;
if (sig instanceof LoopSignal)
recordStatus(session, sig.io.exitCode);
throw await carried(sig, chainStreams(allStdout), mergedIo);
}
}
return [chainStreams(allStdout), mergedIo, lastExec];
}
function chainStreams(sources) {
const nonNull = sources.filter((s) => s !== null && !(s instanceof Uint8Array && s.byteLength === 0));
if (nonNull.length === 0)
return null;
return asyncChain(nonNull);
}
export function isUnwinding(err) {
return err instanceof LoopSignal || err instanceof ReturnSignal || err instanceof ExitSignal;
}
/**
* An unwinding `break`, `continue`, `return` or `exit` with the output the
* construct it leaves had produced put in front of its own, which that
* construct would otherwise drop on the way out (bash wrote it as it went).
* Mirrors Python's carried.
*/
export async function carried(sig, stdout, io) {
if (sig instanceof LoopSignal) {
sig.stdout = chainStreams([stdout, sig.stdout]);
sig.io = await io.merge(sig.io);
return sig;
}
sig.stderr = concat([await materialize(io.stderr), sig.stderr]);
if (sig instanceof ReturnSignal)
sig.stdout = chainStreams([stdout, sig.stdout]);
else
sig.stdout = concat([await materialize(stdout), sig.stdout ?? new Uint8Array()]);
return sig;
}
/**
* Put what an unwinding construct's process substitutions wrote as they
* finished behind the output the signal already carries. Mirrors Python's
* trailed.
*/
export async function trailed(sig, stdout, stderr) {
if (stdout.byteLength === 0 && stderr.byteLength === 0)
return;
if (sig instanceof LoopSignal) {
sig.stdout = chainStreams([sig.stdout, stdout]);
if (stderr.byteLength > 0)
sig.io.stderr = concat([await materialize(sig.io.stderr), stderr]);
return;
}
sig.stderr = concat([sig.stderr, stderr]);
if (sig instanceof ReturnSignal)
sig.stdout = chainStreams([sig.stdout, stdout]);
else
sig.stdout = concat([sig.stdout ?? new Uint8Array(), stdout]);
}
/**
* What a function or a sourced file gives back as it returns: its output and
* status, with what its RETURN action wrote after the output. A `return` in
* a function's action returns from the function with its status. A sourced
* file has returned by the time its action runs, so a `return` there leaves
* the function around it, or only complains at the top level. An `exit`,
* or that `return`, leaves with the output in front of its own. Mirrors
* Python's returning.
*/
export async function returning(executeFn, session, stdin, callStack, stdout, io, sink = null) {
const frame = callStack.current;
frame.closed = frame.sourced;
if (session.functionNames !== null)
session.functionNames = callStack.functionNames();
const outputs = [stdout];
try {
io = await land(await runReturnTrap(executeFn, session, stdin, callStack), sink, outputs, io);
}
catch (sig) {
if (!isUnwinding(sig))
throw sig;
const left = await carried(sig, stdout, io);
if (!(left instanceof ReturnSignal) || frame.sourced)
throw left;
return [
left.stdout,
new IOResult({
stderr: left.stderr.byteLength > 0 ? left.stderr : null,
exitCode: left.exitCode,
}),
];
}
return [chainStreams(outputs), io];
}
/**
* What a child shell reports when an `Unwinding` ends it: what it wrote, its
* diagnostic, and its status, `exit`'s contained one, `return`'s own, or that
* of `break` or `continue`. A child running one simple command is the shell
* `exit` ends (`simple`), so it reports `exit`'s own status (`: ${U?} | cat`
* is 127, `( : ${U?} ) | cat` is 1), unless the signal left text `eval` or
* `source` ran. Mirrors Python's ended.
*/
export function ended(sig, simple = false) {
if (sig instanceof LoopSignal) {
return new IOResult({ stdout: sig.stdout, stderr: sig.io.stderr, exitCode: sig.io.exitCode });
}
return new IOResult({
stdout: sig.stdout,
stderr: sig.stderr.byteLength > 0 ? sig.stderr : null,
exitCode: sig instanceof ExitSignal && (!simple || sig.sourced) ? sig.containedCode : sig.exitCode,
});
}
/**
* Take what a nested line wrote before it left (an `exec`'d command, an ERR
* or RETURN action), for the redirects it ran under to route. An EXIT
* action's output, the `cleanup` at its end, goes around them, and what the
* other unwinding signals carry went through them already. Mirrors Python's
* take_stdout.
*/
export async function takeStdout(sig) {
if (!(sig instanceof ExitSignal || sig instanceof ReturnSignal) || !sig.unrouted)
return new Uint8Array();
const written = await materialize(sig.stdout);
const cut = written.byteLength - (sig instanceof ExitSignal ? sig.cleanup.byteLength : 0);
sig.stdout = cut < written.byteLength ? written.subarray(cut) : null;
return written.subarray(0, cut);
}
/**
* Take the diagnostic an `Unwinding` carries, for the redirects it was
* written under to route. Mirrors Python's take_stderr.
*/
export async function takeStderr(sig) {
if (sig instanceof LoopSignal) {
const diagnostic = await materialize(sig.io.stderr);
sig.io.stderr = null;
return diagnostic;
}
const diagnostic = sig.stderr;
sig.stderr = new Uint8Array();
return diagnostic;
}
/**
* Fold a `break` or `continue` into the loop it reached; one aimed further
* out (`break 2`) goes on with a level spent and the loop's output in front
* of its own. Mirrors Python's _absorbed.
*/
async function absorbed(sig, allStdout, mergedIo) {
allStdout.push(sig.stdout);
const merged = await mergedIo.merge(sig.io);
if (sig.levels > 1) {
sig.stdout = chainStreams(allStdout);
sig.io = merged;
sig.levels -= 1;
throw sig;
}
return merged;
}
function collectLoopResult(allStdout, mergedIo, label) {
const execNode = new ExecutionNode({ command: label, exitCode: mergedIo.exitCode });
const combined = chainStreams(allStdout);
return [combined, mergedIo, execNode];
}
export async function handleIf(run, branches, elseBody, session) {
// Each test and the branch read the fd 0 the `if` started with, so an
// `exec < f` or `exec <&-` in one reaches the next; a test runs with
// `set -e` ignored.
const bound = fd0Binding(session);
// What the tests wrote stays, ahead of what the branch writes.
const leadStdout = [];
let lead = new IOResult();
try {
let chosen = elseBody ?? [];
for (const [test, body] of branches) {
const [stdout, io] = await ignoringErrexit(session, () => run(test, bound));
leadStdout.push(stdout);
lead = await lead.merge(io);
if (io.exitCode === 0) {
chosen = body;
break;
}
}
const [stdout, io, lastExec] = await run(chosen, bound);
return [chainStreams([...leadStdout, stdout]), await lead.merge(io), lastExec];
}
catch (sig) {
if (!isUnwinding(sig))
throw sig;
throw await carried(sig, chainStreams(leadStdout), lead);
}
}
// `set -n` inside a loop body has to stop the *driver* too, not only the
// statements: `executeNode` refuses every node while the option is on, so
// the `break` or the false condition the driver is waiting for is one of
// the refused nodes and it would spin to MAX_WHILE. GNU never runs the
// loop at all, which is what falling straight out of it produces.
export async function handleFor(run, variable, values, body, context, policies = null) {
const session = context.session;
let mergedIo = new IOResult();
const allStdout = [];
const view = sessionView(session, policies, context.frame.diagnostics);
// The loop variable is the shell's own write: readonly is bash's
// rule, checked up front so the loop never starts, exactly as bash
// refuses `for x` on a readonly x before the first iteration.
if (view.isReadonly(variable)) {
const err = encodeText(`bash: ${variable}: readonly variable\n`);
return collectLoopResult([], new IOResult({ exitCode: 1, stderr: err }), 'for');
}
// Each iteration reads the fd 0 the loop started with, so an `exec < f` in
// one reaches the next.
const bound = fd0Binding(session);
for (const val of values) {
if (session.shellOptions.noexec === true)
break;
// bash keeps `for f in sub/*.txt` matches relative, so the loop
// variable takes the typed form; a policy denial aborts the loop
// before its body runs.
try {
await view.set(variable, wordText(val));
}
catch (err) {
if (err instanceof ArithError)
throw err.signal('', true);
if (!(err instanceof PolicyDenied))
throw err;
mergedIo = await mergedIo.merge(new IOResult({ exitCode: 1, stderr: encodeText(`${err.message}\n`) }));
break;
}
try {
const [stdout, io] = await run(body, bound);
allStdout.push(stdout);
mergedIo = await mergedIo.merge(io);
}
catch (sig) {
if (!(sig instanceof LoopSignal))
throw sig;
mergedIo = await absorbed(sig, allStdout, mergedIo);
if (sig instanceof BreakSignal)
break;
continue;
}
}
// The loop variable is an ordinary variable in bash and keeps its
// last value after the loop (`for X in a b; do :; done; echo $X`
// prints b); nothing is put back.
return collectLoopResult(allStdout, mergedIo, 'for');
}
export async function handleWhile(run, test, body, session, until = false) {
const label = until ? 'until' : 'while';
let mergedIo = new IOResult();
const allStdout = [];
// Each test and body reads the fd 0 the loop started with, so an
// `exec < f` in one reaches the rest.
const bound = fd0Binding(session);
let i = 0;
for (; i < MAX_WHILE; i++) {
if (session.shellOptions.noexec === true)
break;
try {
const [condStdout, condIo] = await ignoringErrexit(session, () => run(test, bound));
allStdout.push(condStdout);
mergedIo = await mergedIo.merge(new IOResult({ stderr: condIo.stderr, exitCode: mergedIo.exitCode }));
if ((condIo.exitCode === 0) === until)
break;
const [stdout, io] = await run(body, bound);
allStdout.push(stdout);
mergedIo = await mergedIo.merge(io);
}
catch (sig) {
if (!(sig instanceof LoopSignal)) {
if (isUnwinding(sig))
throw await carried(sig, chainStreams(allStdout), mergedIo);
throw sig;
}
mergedIo = await absorbed(sig, allStdout, mergedIo);
if (sig instanceof BreakSignal)
break;
continue;
}
}
if (i === MAX_WHILE)
capped(mergedIo, label);
return collectLoopResult(allStdout, mergedIo, label);
}
/**
* Say on stderr that a loop stopped at MAX_WHILE iterations, mirage's own
* cap (bash has none), so a runaway loop or a `while read` over a longer
* stream is never cut short silently. Mirrors Python's _capped.
*/
function capped(io, label) {
const warn = encodeText(`warning: ${label} loop terminated after ${MAX_WHILE.toString()} iterations\n`);
io.stderr = io.stderr instanceof Uint8Array ? concat([io.stderr, warn]) : warn;
}
/**
* Run bash's C-style for: ((init; cond; update)) around a body.
*
* `evalExpr` evaluates one expression slot to its integer value (the
* default when the slot is empty) and throws ArithError with the
* offending expression text on an invalid expression, or ReadonlyVariableError
* when it assigns to a readonly variable; bash aborts the loop with
* status 1, keeping the output of iterations that ran. The update
* expression still runs after `continue`, per bash. A PolicyDenied is a
* header expression assigning a hidden name.
*/
export async function handleCfor(run, exprs, body, evalExpr, session) {
let mergedIo = new IOResult();
const allStdout = [];
const bound = fd0Binding(session);
try {
await evalExpr(exprs[0] ?? [], 0);
let i = 0;
for (; i < MAX_WHILE; i++) {
if (session.shellOptions.noexec === true)
break;
if ((await evalExpr(exprs[1] ?? [], 1)) === 0)
break;
try {
const [stdout, io] = await run(body, bound);
allStdout.push(stdout);
mergedIo = await mergedIo.merge(io);
}
catch (sig) {
if (!(sig instanceof LoopSignal))
throw sig;
mergedIo = await absorbed(sig, allStdout, mergedIo);
if (sig instanceof BreakSignal)
break;
await evalExpr(exprs[2] ?? [], 0);
continue;
}
await evalExpr(exprs[2] ?? [], 0);
}
if (i === MAX_WHILE)
capped(mergedIo, 'for');
}
catch (err) {
if (!(err instanceof ArithError) &&
!(err instanceof ReadonlyVariableError) &&
!(err instanceof PolicyDenied)) {
throw err;
}
if (err instanceof ArithError && err.inSubscript) {
throw await carried(err.signal(), chainStreams(allStdout), mergedIo);
}
const prefix = err instanceof ArithError ? 'bash: ((: ' : 'bash: ';
const errBytes = encodeText(`${prefix}${err.message}\n`);
mergedIo = await mergedIo.merge(new IOResult({ exitCode: 1, stderr: errBytes }));
mergedIo.exitCode = 1;
}
return collectLoopResult(allStdout, mergedIo, 'for');
}
/**
* Run the first arm whose pattern matches `word`, and after it the arms its
* terminator reaches: `;&` falls into the next arm's body untested, `;;&`
* tests the rest, `;;` stops.
*/
export async function handleCase(run, word, items, session, expand) {
const allStdout = [];
let mergedIo = new IOResult();
let lastExec = new ExecutionNode({ command: 'case', exitCode: 0 });
let fallthrough = false;
// Each arm reads the fd 0 the `case` started with, so an `exec < f` in one
// reaches an arm it falls into.
const bound = fd0Binding(session);
for (const [patterns, body, terminator] of items) {
// An arm a `;;&` left can have turned extglob on for this one.
const extglob = session.shopts.extglob ?? false;
try {
let matched = fallthrough;
if (!matched) {
for (const pattern of patterns) {
if (fnmatch(word, await expand(pattern), extglob)) {
matched = true;
break;
}
}
}
if (!matched)
continue;
const [stdout, io, execNode] = await run(body, bound);
allStdout.push(stdout);
mergedIo = await mergedIo.merge(io);
lastExec = execNode;
}
catch (sig) {
if (!isUnwinding(sig))
throw sig;
throw await carried(sig, chainStreams(allStdout), mergedIo);
}
fallthrough = terminator === ';&';
if (session.errexitExiting || (terminator !== ';&' && terminator !== ';;&'))
break;
}
return [chainStreams(allStdout), mergedIo, lastExec];
}
/**
* The select menu as bash 5.2 prints it: column-major in
* `$COLUMNS` (80 when unset or not positive), each cell padded with tabs
* to an 8-wide stop, one entry per row when they all fit on one.
*/
function selectMenu(words, columns) {
const width = atoi(columns);
const indexLen = String(words.length).length;
const cell = Math.max(...words.map((w) => Array.from(w).length)) + indexLen + 4;
let rows = Math.ceil(words.length / Math.max(Math.floor((width > 0 ? width : 80) / cell), 1));
if (rows === 1)
rows = words.length;
const lines = [];
for (let row = 0; row < rows; row++) {
let line = '';
let col = 0;
for (let ind = row, pos = 0; ind < words.length; ind += rows, pos++) {
while (col < pos * cell) {
const tab = Math.floor((pos * cell) / 8) > Math.floor(col / 8);
line += tab ? '\t' : ' ';
col = tab ? col + 8 - (col % 8) : col + 1;
}
const label = String(ind + 1).padStart(pos === 0 ? String(rows).length : indexLen);
const text = `${label}) ${words[ind] ?? ''}`;
line += text;
col += Array.from(text).length;
}
lines.push(line + '\n');
}
return lines.join('');
}
/**
* Run bash's select loop: menu to stderr, choice read from stdin. Each
* iteration prompts with `$PS3` (`#? ` when unset), takes a line the way a
* bare `read` does into REPLY, and sets the variable to the chosen entry
* (empty for an out-of-range or non-numeric reply, like bash). An empty
* reply redisplays the menu without running the body, and so does a body
* that empties REPLY; end of input prints a newline and ends the loop with
* status 1. An empty list runs nothing. `sink` is where the body's
* statements write as they finish, so the loop's own newline lands in
* order.
*/
export async function handleSelect(run, variable, values, body, context, stdin = null, policies = null, signal, sink) {
const session = context.session;
let mergedIo = new IOResult();
const allStdout = [];
const view = sessionView(session, policies, context.frame.diagnostics);
const lines = stdin !== null ? lineBuffer(stdin) : null;
const bound = fd0Binding(session);
const words = values.map((v) => wordText(v));
let showMenu = words.length > 0;
for (let i = 0; i < (words.length > 0 ? MAX_WHILE : 0); i++) {
if (session.shellOptions.noexec === true)
break;
const env = visibleEnv(session);
const menu = showMenu ? selectMenu(words, env.COLUMNS ?? '') : '';
const prompt = menu + (env.PS3 ?? '#? ');
mergedIo = await mergedIo.merge(new IOResult({ stderr: prompt !== '' ? encodeText(prompt) : null }));
const reply = lines !== null ? await readReply(lines, signal) : null;
// A failed choice read (end of input, a readonly REPLY) ends the prompt
// line; a readonly loop variable fails after it.
let frozen = null;
if (reply !== null && view.isReadonly('REPLY'))
frozen = 'REPLY';
else if (reply !== null && reply !== '' && view.isReadonly(variable))
frozen = variable;
if (reply === null || frozen === 'REPLY') {
if (sink !== undefined)
await sink.emit(Channel.STDOUT, encodeText('\n'));
else
allStdout.push(encodeText('\n'));
}
if (reply === null || frozen !== null) {
const err = frozen !== null ? encodeText(`bash: ${frozen}: readonly variable\n`) : null;
mergedIo = await mergedIo.merge(new IOResult({ exitCode: 1, stderr: err }));
break;
}
const number = /^\s*([+-]?\d+)[ \t]*$/.exec(reply);
const index = number !== null ? Number(number[1]) : 0;
try {
await view.set('REPLY', reply);
showMenu = reply === '';
if (showMenu)
continue;
await view.set(variable, index >= 1 && index <= words.length ? (words[index - 1] ?? '') : '');
}
catch (err) {
if (err instanceof ArithError)
throw err.signal('', true);
if (!(err instanceof PolicyDenied))
throw err;
mergedIo = await mergedIo.merge(new IOResult({ exitCode: 1, stderr: encodeText(`${err.message}\n`) }));
break;
}
try {
const [stdout, io] = await run(body, bound);
allStdout.push(stdout);
mergedIo = await mergedIo.merge(io);
}
catch (sig) {
if (!(sig instanceof LoopSignal))
throw sig;
mergedIo = await absorbed(sig, allStdout, mergedIo);
if (sig instanceof BreakSignal)
break;
}
showMenu = (visibleEnv(session).REPLY ?? '') === '';
}
// The loop variable is an ordinary variable in bash and keeps its
// last value after the loop (`for X in a b; do :; done; echo $X`
// prints b); nothing is put back.
return collectLoopResult(allStdout, mergedIo, 'select');
}
//# sourceMappingURL=control.js.map