UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

416 lines • 20.1 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 { 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