@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
240 lines • 10.4 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 { 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