@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
270 lines • 12.6 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 { currentCaller } from "../../context/session_context.js";
import { shellJoin } from "../../shell/join.js";
import { abortable } from "../../utils/abort.js";
import { rstripSlash } from "../../utils/slash.js";
import { Runtime } from "../base.js";
import { EXTERNAL_COMMANDS } from "../constants.js";
import { LINE_EXECUTOR, PROCESS_EXECUTOR, } from "../mixin.js";
import { BASE_CONFIG_KEYS, checkMountType, } from "./config.js";
import { EXEC_ID, FAKETIME_PROBE_SECONDS, faketimeEnv, faketimeProbe, REAP_ABSENT, REAP_WAIT_SECONDS, reapLine, } from "./constants.js";
import { boundSystemClock } from "../../utils/clock.js";
import { isPipeTransport } from "./mixin.js";
import { SandboxMounts } from "./mounts.js";
/**
* A runtime that executes programs in a sandbox the user runs.
*
* Captures default to unresolved program names. An explicit WHOLE_LINE
* delegates whole shell lines.
*
* Mirage never creates, provisions, or deletes sandboxes: you bring
* your own (a running container, a live Daytona or E2B sandbox) and
* the provider config says how to reach it. The workspace reaches the
* sandbox at the same prefixes as the host's, so the session cwd and
* every path in a line resolve unchanged: a provider with a `mountType`
* serves them itself (SandboxMounts), otherwise serve them inside it
* yourself (`mirage workspace create` in the image entrypoint or by
* hand). There is no interpreter entry point. Subclasses adapt one
* provider by implementing connect() and execArgv(), and PipeTransport
* for a `mountType`; every run derives from execArgv: a whole line runs
* as `/bin/sh -c`, a program from its argv, and a stream hands its
* output over as it comes. Routing and captures are inherited.
* Constructed like every runtime (captures, config); config is
* how to reach the sandbox, coerced against the provider's own key list.
*/
export class RemoteSandbox extends Runtime {
// Lines execute on the provider's machine and act on that machine's
// world (its filesystem, its network); the workspace gate never sees
// those effects, however well isolated the sandbox itself is.
reach = 'remote';
[LINE_EXECUTOR] = true;
[PROCESS_EXECUTOR] = true;
// Connect-once latch: the first captured line connects; later lines
// just execute. Single-flight so concurrent first lines share one
// connect, and a failed connect clears the slot so the next line
// retries.
connecting = null;
mounts = null;
// The libfaketime each sandbox holds (`sandboxKey`), asked once the first
// program runs there under a system_time: null for none. Concurrent first
// runs share the probe; a failed probe clears its slot so a later run asks
// again.
faketime = new Map();
constructor(options = {}, configKeys) {
super(options, [EXTERNAL_COMMANDS], configKeys ?? BASE_CONFIG_KEYS);
// The sandbox refinement of the base's coerced copy: the shared
// collection fields are always present (env).
const config = this.config;
this.config = { ...config, env: { ...config.env } };
checkMountType('sandbox', config.mountType);
}
get fileEffectsDispatched() {
return (this.mounts?.fileEffectsDispatched === true &&
this.binding?.mountPrefixes != null &&
!this.binding.mountPrefixes().includes('/'));
}
servesSession(restrictedMounts) {
return ((this.mounts !== null || this.config.mountType != null) &&
restrictedMounts.every((prefix) => !['', '/dev'].includes(rstripSlash(prefix))));
}
/**
* Run one raw line in the sandbox, connecting once.
*
* The line, cwd, and paths pass through verbatim: the sandbox is
* expected to serve the workspace at the same prefixes as the host,
* so nothing is rewritten. The session environment merges over the
* config environment.
*
* @param _engine none; a sandbox keeps its own connection.
* @param _paths unused; the line names its paths.
* @param _texts unused.
* @param ctx the line in `flags.c`, the bytes piped into it, the session
* environment and working directory, the signal that stops it.
* @param output receives stdout as it arrives.
* @param errors receives stderr as it arrives.
*/
runLine(_engine, _paths, _texts, ctx, output, errors) {
const line = ctx.flags.c;
return this.runArgv(['/bin/sh', '-c', typeof line === 'string' ? line : ''], ctx, output ?? null, errors ?? null);
}
runProcess(_engine, _paths, texts, ctx, output, errors) {
return this.runArgv(texts, ctx, output ?? null, errors ?? null);
}
async settle() {
await this.mounts?.sync();
}
/**
* Connect once, bring the served mounts in step, then run.
*
* @param argv the program and its arguments.
* @param ctx the run's stdin, environment, cwd and signal.
* @param output receives stdout as it arrives.
* @param errors receives stderr as it arrives.
*/
async runArgv(argv, ctx, output, errors) {
// Served here rather than in the constructor, which runs before a
// subclass names itself.
if (this.mounts === null)
this.serveMounts(this.config.mountType ?? null);
await this.ensureConnected(ctx.signal);
const refusal = (await this.mounts?.admit(this.binding, ctx.signal)) ?? null;
if (refusal !== null)
return refusal;
const given = { ...this.config.env, ...ctx.env };
const clock = await this.systemTimeEnv(given, ctx.signal);
// A deadline that passed during the probe starts nothing.
ctx.signal?.throwIfAborted();
const execId = crypto.randomUUID().replaceAll('-', '');
const runner = currentCaller().process;
runner?.addRun(`box:${execId}`, { runtime: this.name, tag: execId, command: shellJoin(argv) });
try {
return await this.execArgv(argv, ctx.stdin, { ...given, ...clock, [EXEC_ID]: execId }, ctx.cwd.virtual, output, errors, ctx.signal);
}
catch (error) {
if (ctx.signal?.aborted === true)
await this.reapRun(execId, runner?.signal ?? 15);
throw error;
}
finally {
runner?.dropRun(`box:${execId}`);
}
}
/**
* Stop a run a lapsed workspace left in the box: every process its
* `EXEC_ID` tag marks gets `signum`, then KILL a second later. Connecting
* is bounded as the stop is (`REAP_WAIT_SECONDS`). Answers true once the
* box held the run's processes and they were stopped, false when it holds
* none of them, as a box other than the run's does; throws when the box
* could not be reached or the stop failed.
*
* @param tag the run's `EXEC_ID`, as its process record has it.
* @param signum the first signal.
*/
async reap(tag, signum) {
await this.ensureConnected(AbortSignal.timeout(REAP_WAIT_SECONDS * 1000));
const code = await this.reapRun(tag, signum);
if (code !== 0 && code !== REAP_ABSENT)
throw new Error(`${this.name}: run ${tag} not stopped`);
return code === 0;
}
/**
* Stop what a cancelled run left in the box (`EXEC_ID`), for at most
* `REAP_WAIT_SECONDS`: a box that stopped answering would otherwise hold
* the cancel.
*
* @param execId the run's id.
* @param signum the signal the run is stopped with first.
* @returns the stop line's status (`reapLine`); null when the box did not
* answer.
*/
async reapRun(execId, signum) {
const signal = AbortSignal.timeout(REAP_WAIT_SECONDS * 1000);
try {
const done = await abortable(this.execArgv(['/bin/sh', '-c', reapLine(execId, signum)], null, { ...this.config.env }, '/', null, null, signal), signal);
if (done.exitCode !== 0 && done.exitCode !== REAP_ABSENT) {
console.debug(`${this.name}: run ${execId} not stopped: exit ${String(done.exitCode)}`);
}
return done.exitCode;
}
catch (error) {
console.debug(`${this.name}: run ${execId} not stopped: ${String(error)}`);
return null;
}
}
/** Let the sandbox see the workspace by `mountType`; null serves nothing. */
serveMounts(mountType) {
if (mountType === null)
return;
if (!isPipeTransport(this))
throw new Error(`${this.name}: mountType needs a pipe transport`);
this.mounts = new SandboxMounts(this.name, mountType, (line, signal) => this.execLine(line, null, { ...this.config.env }, '/', signal), this.openPipe.bind(this));
}
disconnected() {
this.connecting = null;
}
/**
* What a program in this sandbox needs to read the running workspace's
* system time (`faketimeEnv`), through the libfaketime the sandbox holds.
* Empty when the workspace keeps the host's time, and when the sandbox has
* no libfaketime, where the program runs on the sandbox's clock. Mirrors
* Python's `SandboxRuntime._system_time_env`.
*/
async systemTimeEnv(env, signal) {
if (boundSystemClock() === null)
return {};
const key = this.sandboxKey();
let probe = this.faketime.get(key);
if (probe === undefined) {
// Its own deadline, not the first caller's: every waiting run shares it.
// It is also raced, as a provider may not stop a line on its signal.
const deadline = AbortSignal.timeout(FAKETIME_PROBE_SECONDS * 1000);
probe = abortable(this.execLine(faketimeProbe(), null, { ...this.config.env }, '/', deadline), deadline).then((found) => {
if (found.exitCode !== 0)
throw new Error(`${this.name}: libfaketime probe exited ${String(found.exitCode)}`);
return new TextDecoder().decode(found.stdout).trim() || null;
});
this.faketime.set(key, probe);
}
let library;
try {
library = await abortable(probe, signal);
}
catch (err) {
// This run's own deadline: the probe goes on for the others.
if (signal?.aborted === true)
throw err;
// Unanswered: this run keeps the sandbox's clock, and a later one asks again.
if (this.faketime.get(key) === probe)
this.faketime.delete(key);
return {};
}
return library === null ? {} : faketimeEnv(library, env);
}
/**
* Which sandbox a run of the current session goes to, which the libfaketime
* probe is kept for: one per runtime unless a provider picks its sandbox
* per session. Mirrors Python's `SandboxRuntime._sandbox_key`.
*/
sandboxKey() {
return '';
}
async ensureConnected(signal) {
signal?.throwIfAborted();
this.connecting ??= this.connect().catch((err) => {
this.connecting = null;
throw err;
});
await abortable(this.connecting, signal);
signal?.throwIfAborted();
}
/**
* Execute one shell line inside the sandbox, as `/bin/sh -c`.
*
* @param line the raw shell line.
* @param stdin bytes piped into the line.
* @param env the merged environment.
* @param cwd the working directory, passed through verbatim.
* @param signal stops the line.
*/
execLine(line, stdin, env, cwd, signal) {
return this.execArgv(['/bin/sh', '-c', line], stdin, env, cwd, null, null, signal);
}
/** Unmount what the sandbox was served; the sandbox itself is the user's. */
async close() {
await this.mounts?.close();
await super.close();
}
}
//# sourceMappingURL=base.js.map