UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

240 lines • 10.4 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 { runWithCaller } from "../context/session_context.js"; import { liveEgress } from "../io/gateway.js"; import { Activity } from "../utils/activity.js"; import { refusedRun } from "./binding.js"; import { coerceRuntimeConfig } from "./config.js"; import { UnsupportedExecutionError } from "./errors.js"; import { isLineExecutor, isProcessExecutor } from "./mixin.js"; /** * An engine the workspace can route commands or whole lines to. * * A runtime is to its commands what the regex engine is to grep: the * machinery inside a handler, invisible to the dispatcher. Each runtime * declares the command names it captures; a command binds to the first * runtime in the workspace's ordered list that captures it. Its hooks take * what a handler takes, `(engine, paths, texts, ctx)`: the engine (`E`) is * built once by `openEngine`; a runtime with none declares * `Runtime<undefined>`. * * The base holds only what every tier shares: the registry name, the * captured command names, the coerced config and the engine. What a * runtime can DO is declared by its tier and mixins, detected by type and * never by probing: LanguageRuntime interprets code in its language * (run), LineExecutorMixin takes whole lines (runLine), ProcessExecutorMixin runs * an argv (runProcess). * * Every runtime is constructed the same way (captures, config); each * subclass hands the base its class-default captures and its config key * list, so a config field the runtime does not have fails loud * (config_cls in Python). */ export class Runtime { captures; /** * Which entry points this runtime's code has to the outside world (see * RuntimeReach): 'workspace' when the workspace dispatch is its only one, * as the bridged engines (monty, pyodide, quickjs) and the workspace * routing marker declare, 'process' or 'remote' when the code can * act around that gate. The default is 'process', the no-promise * claim, so a custom runtime must declare a narrower reach * explicitly rather than inherit it. Embedders read the aggregate: * only a world in which every runtime reaches 'workspace' makes "agent * code cannot bypass mount modes and policy" a true statement, * which is what the dsh adapter's sandbox claim is built from; one * wider runtime voids it. */ reach = 'process'; filesystem = []; /** The runtime's coerced implementation knobs. */ config; binding = null; activity = new Activity(); retired = false; opening = null; closing = null; constructor(options = {}, defaultCaptures = [], configKeys = []) { this.captures = options.captures !== undefined ? options.captures.slice() : defaultCaptures.slice(); this.config = coerceRuntimeConfig(options.config, configKeys); } /** Whether workspace file mutations already invalidate through dispatch. */ get fileEffectsDispatched() { return this.reach === 'workspace'; } /** * Land the file writes this runtime still holds for a line. The executor * calls it when a line ends, however it ended, so the line's writes are * in the workspace by the time it returns. Holds none by default; a * sandbox serving the workspace over SFTP lands what its servers hold. */ settle() { return Promise.resolve(); } /** Attach workspace services; a runtime instance belongs to one workspace. */ bind(binding) { if (this.retired) throw new Error(`${this.name}: runtime was removed from its workspace; construct a new one`); if (this.binding !== null && this.binding !== binding) throw new Error(`${this.name}: runtime is already bound to another workspace`); this.binding = binding; } /** * Run once, as the session the bound workspace captures. The capture * supplies the files (`ctx.vfs`, judged as the program's own calls) and * the session; the caller's `ctx` the rest. * * @param paths the path operands (a script). * @param texts the text operands (argv; a process's program first). * @param ctx the run's context. * @param opts `kind`: code in the runtime's language (the default), a * whole line (`sh -c` with `ctx.flags.c`), or a process; `output`: * where the run writes its output as it comes, absent to collect it in * the result; `errors`: the same for its stderr. */ async execute(paths, texts, ctx, opts = {}) { const release = this.admit(); try { const kind = opts.kind ?? 'code'; const capture = this.binding?.capture(); const restricted = capture?.restrictedMounts ?? []; if (this.reach !== 'workspace' && restricted.length > 0 && !this.servesSession(restricted)) return refusedRun(this.name, "runtime cannot enforce this session's file restrictions"); // A process on this machine opens its connections around the gateway, // so a workspace whose network is limited, or whose policies judge // each connection, runs none. if (this.reach === 'process') { for (const egress of liveEgress()) { if (egress.rule !== null || (await egress.hooked)) return refusedRun(this.name, "runtime cannot enforce this workspace's network"); } } const engine = await this.engine(); if (capture === undefined) return await this.runAs(kind, engine, [...paths], [...texts], ctx, opts.output, opts.errors); const { vfs, session } = capture.ctx; const processes = ctx.processes ?? capture.ctx.processes; const runCtx = { ...ctx, ...(session !== undefined ? { session } : {}), ...(processes !== undefined ? { processes } : {}), }; if (vfs === undefined) delete runCtx.vfs; else runCtx.vfs = vfs; return await runWithCaller(capture.caller, () => this.runAs(kind, engine, [...paths], [...texts], runCtx, opts.output, opts.errors)); } finally { release(); } } /** Hand the run to the capability its kind asks for. */ async runAs(kind, engine, paths, texts, ctx, output, errors) { if (kind === 'process' && texts.length === 0) throw new Error('process argv must not be empty'); if (kind === 'code') return this.run(engine, paths, texts, ctx, output, errors); if (kind === 'line' && isLineExecutor(this)) return this.runLine(engine, paths, texts, ctx, output, errors); if (kind === 'process' && isProcessExecutor(this)) return this.runProcess(engine, paths, texts, ctx, output, errors); throw new UnsupportedExecutionError(`${this.name}: ${kind} execution is unsupported`); } /** * Run code in this runtime's language; only a language runtime has one. * * @param _engine the install's engine. * @param _paths the script operand, if any. * @param _texts the program's argv. * @param _ctx the run's context. * @param _output receives stdout as it comes; undefined returns it in * the result. * @param _errors the same for stderr. */ run(_engine, _paths, _texts, _ctx, _output, _errors) { return Promise.reject(new UnsupportedExecutionError(`${this.name}: code execution is unsupported`)); } /** Build the engine every run reuses, once; the default is none. */ openEngine() { return Promise.resolve(undefined); } /** * Release what `openEngine` built. * * @param _engine the engine. */ closeEngine(_engine) { return Promise.resolve(); } /** The engine, opened once and shared; a failed open is retried. */ engine() { const opening = (this.opening ??= this.openEngine()); return opening.catch((err) => { if (this.opening === opening) this.opening = null; throw err; }); } /** * Whether every restricted mount is served through this caller's gate. * * @param _restrictedMounts the mounts the session restricts. */ servesSession(_restrictedMounts) { return false; } /** Count one unit of work, refused once the runtime is retired. */ admit() { if (this.retired) throw new Error(`${this.name}: runtime was removed from the workspace`); return this.activity.acquire(); } /** Refuse new executions and binds, then wait for running ones. */ async retire() { this.retired = true; await this.activity.wait(); } /** * Close the engine, when one was opened. A close that comes while another * runs waits for that one rather than finding nothing left to close. */ async close() { const closing = (this.closing ??= this.dropEngine()); try { await closing; } finally { if (this.closing === closing) this.closing = null; } } async dropEngine() { const opening = this.opening; this.opening = null; if (opening === null) return; let engine; try { engine = await opening; } catch (err) { console.debug(`${this.name}: engine never opened`, err); return; } await this.closeEngine(engine); } } //# sourceMappingURL=base.js.map