UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

254 lines • 11.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, withCaller } from "../../../context/session_context.js"; import { BytePipe } from "../../../io/pipe.js"; import { PipeClosed } from "../../../io/errors.js"; import { OutputStream, closeQuietly } from "../../../io/stream.js"; import { IOResult, OutputState, materialize } from "../../../io/types.js"; import { QuickJsUnavailableError } from "../../../runtime/js/quickjs/errors.js"; import { MontyUnavailableError } from "../../../runtime/python/monty/errors.js"; import { PyodideUnavailableError } from "../../../runtime/python/pyodide/errors.js"; import { encodeText } from "../../../shell/bytes.js"; import { STOP_WAIT_SECONDS } from "../../../shell/job_table/constants.js"; import { errorVirtualPath, isFsError } from "../../../errors/fs.js"; import { CommandTimeoutError } from "../../../errors/types.js"; import { ABORT_JOIN_MS, hasAborted, runCancellable, mergeSignals } from "../../../utils/abort.js"; import { concat } from "../../../utils/bytes.js"; import { CANNOT_EXECUTE_STATUS, NOT_FOUND_STATUS, SIGPIPE_STATUS, } from "../../../shell/constants.js"; /** * A runtime run as a command's output, handed over as it runs. * * The one mapping from a run to a command's output shape: the interpreter * commands, a script CLI, a program a runtime captured and a whole line all * answer through here. The run starts when its output is first read and * writes stdout into a bounded pipe, so a reader that falls behind holds the * program back and the output is never held whole; what a transport could * not stream lands at the end. The status, and the stderr the run returned, * settle on the result as the output ends (`IOResult.output`), stderr after * what the result already held. A reader that stops early stops the run, * which then exits 141, as SIGPIPE leaves a program, and keeps the stderr it * had written. A stopped run that does not end within the stop bound is left * running, as a stopped handler is (`runCancellable`). Mirrors Python's * `run_output`, whose cancel ends the run outright. * * @param run starts the run, given where it writes stdout, where it writes * stderr and the signal that stops it. * @param producer what made the output, for a program run past the * workspace's own commands. * @param errors takes stderr as the run writes it, after the stdout written * before it has been read; undefined lands it on the result's stderr. * @param stdin the run's input, closed once the output ends. * @param signal the command's own abort, which stops the run too. */ export function runOutput(run, producer = null, errors, stdin = null, signal) { const pipe = new BytePipe(); const stop = new AbortController(); const runSignal = mergeSignals(signal, stop.signal) ?? stop.signal; const caller = currentCaller(); const io = new IOResult({ producer }); const state = new OutputState(); io.output = state; let task; let settled = false; let stopped = false; let closing; const kept = []; // Stdout written before this chunk reaches its reader first, so the two // keep the order the program wrote them in (`2>&1`). const ordered = async (chunk) => { await pipe.drain(); await errors?.(chunk); }; const keep = (chunk) => { kept.push(new Uint8Array(chunk)); return Promise.resolve(); }; // A run its reader or deadline stopped, or a `kill` naming a signal, gets // the stop bound to answer, which a sandbox's reap fits within; a run whose // caller went away releases it soon, as a stopped handler does. const grace = () => hasAborted(signal) && (caller.process?.signal ?? null) === null ? ABORT_JOIN_MS : STOP_WAIT_SECONDS * 1000; const execute = async () => { try { const result = await run((chunk) => pipe.write(chunk), errors === undefined ? keep : ordered, runSignal); if (result.stderr !== null && result.stderr.length > 0) kept.push(result.stderr); if (stopped) return; if (result.stdout.length > 0) await pipe.write(result.stdout); io.exitCode = result.exitCode; pipe.end(); } catch (error) { stop.abort(); pipe.end(error); throw error; } finally { settled = true; } }; const close = () => { closing ??= (async () => { pipe.closeReader(); if (task !== undefined && !settled) { stopped = true; stop.abort(); } if (task !== undefined) { await runCancellable(task, stop.signal, grace).catch((error) => { if (stopped || error instanceof PipeClosed) return; console.debug(`runtime output ended: ${String(error)}`); }); } if (stopped) io.exitCode = SIGPIPE_STATUS; if (kept.length > 0) io.stderr = concat([await materialize(io.stderr), ...kept]); await closeQuietly(stdin); state.finish(); })(); return closing; }; async function* output() { task = execute(); void task.catch(() => undefined); try { yield* pipe.stream(); } finally { await close(); } } return [new OutputStream(withCaller(output(), caller), close), io]; } /** * Whether a thrown error means the runtime itself is missing (exit 127), * not that the program it ran failed. * * Args: * err: the error the runtime threw. */ export function runtimeUnavailable(err) { return (err instanceof QuickJsUnavailableError || err instanceof MontyUnavailableError || err instanceof PyodideUnavailableError); } export function runtimeVersion(label, runtime, env, signal, timeoutSeconds) { const run = async (_output, _errors, stop) => { try { return await runtime.version(env, stop, timeoutSeconds); } catch (err) { if (err instanceof CommandTimeoutError) throw err; const message = err instanceof Error ? err.message : String(err); return { stdout: new Uint8Array(), stderr: encodeText(`${label}: ${message}\n`), exitCode: runtimeUnavailable(err) ? NOT_FOUND_STATUS : 1, }; } }; return runOutput(run, null, undefined, null, signal); } function failed(label, message, exitCode) { return [ null, new IOResult({ exitCode, stderr: new TextEncoder().encode(`${label}: ${message}\n`) }), ]; } /** * The answer of an interpreter command no runtime runs: bash's * `command not found`, exit 127, whatever the operands say. */ export function notBound(label) { return failed(label, 'command not found', NOT_FOUND_STATUS); } /** * Why policy will not let an interpreter command run its program; null * when it may. * * The x check follows the program's entry point. A script operand asks * the per-path entry point about the script's own path, so a session * whose only x grant is one show subtree runs scripts there and nowhere * else; inline code (-c/-e), -m and stdin keep the whole-session rule * (`execAllowed`: any x grant), since no path holds them. Outside a * workspace no per-path entry point is wired and `execAllowed` answers * for files too. Mirrors Python's `exec_refusal`. * * @param label the command name used in the message. * @param script the script operand, null for a program from -c, -m or * stdin (`scriptOperand`). * @param placement the command's exec policy; absent allows. */ export function execRefusal(label, script, placement) { if (script !== null) { const allowed = placement?.execPathAllowed?.(script.virtual) ?? placement?.execAllowed !== false; if (allowed) return null; const display = script.rawPath !== '' ? script.rawPath : script.virtual; return failed(label, `${display}: not in EXEC mode`, CANNOT_EXECUTE_STATUS); } if (placement?.execAllowed !== false) return null; return failed(label, "root mount '/' is not in EXEC mode", CANNOT_EXECUTE_STATUS); } /** * Run an interpreter command's operands on its bound runtime, the program's * output handed over as it runs (`runOutput`). A failure of the runtime * itself answers as the command: a missing engine 127, any other 1, an * in-VM limit interrupt a timeout named after the command, as the * workspace's own timer names it. A script the runtime could not read is * the command's own read failure, left to the executor to render as any * command's is, as Python lets it escape. * * @param label the command name used in messages. * @param runtime the runtime bound to the command. * @param paths the path operands. * @param texts the text operands. * @param ctx the command's context, its flags the runtime's switches. * @param script the script operand (`scriptOperand`), null for a program * from -c, -m or stdin. */ export function runInterpreter(label, runtime, paths, texts, ctx, script) { const run = async (output, errors, signal) => { try { return await runtime.execute(paths, texts, { ...ctx, signal }, { output, errors }); } catch (err) { if (err instanceof CommandTimeoutError) throw new CommandTimeoutError(label, err.seconds); if ((err instanceof Error && err.name === 'AbortError') || hasAborted(signal)) throw err; if (script !== null && isFsError(err) && errorVirtualPath(err) === script.virtual) throw err; const message = err instanceof Error ? err.message : String(err); return { stdout: new Uint8Array(), stderr: encodeText(`${label}: ${message}\n`), exitCode: runtimeUnavailable(err) ? NOT_FOUND_STATUS : 1, }; } finally { // The program may have written past the dispatcher. await ctx.runtime?.settle?.(); } }; return runOutput(run, null, undefined, ctx.stdin, ctx.signal); } //# sourceMappingURL=interpreter.js.map