@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
267 lines • 10.2 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 { feed } from "../../../io/stream.js";
import { abortable } from "../../../utils/abort.js";
import { currentCaller } from "../../../context/session_context.js";
import { RemoteSandbox } from "../base.js";
import { PIPE_TRANSPORT } from "../mixin.js";
import { registerRuntime } from "../../table.js";
import { concat } from "../../../utils/bytes.js";
import { requireId } from "../config.js";
import { EXEC_ID, stopSignal, wrapLine } from "../constants.js";
import { CLOSED_INPUT, PTY_MARK, PTY_RAW } from "./constants.js";
import { loadSdk } from "./sdk.js";
import { E2B_CONFIG_KEYS } from "./config.js";
const ENC = new TextEncoder();
/** Where the PTY marker ends in `bytes`, or -1 while it has not come. */
function pastMark(bytes) {
outer: for (let at = 0; at + PTY_MARK.length <= bytes.length; at++) {
for (let i = 0; i < PTY_MARK.length; i++) {
if (bytes[at + i] !== PTY_MARK[i])
continue outer;
}
return at + PTY_MARK.length;
}
return -1;
}
/**
* A PTY's stdin as a pipe writer. Writes queue, and one loop sends what
* has queued in one E2B call, in order: each call is a round trip, so a
* reply split into many writes would otherwise cost one trip per write.
* A send that fails hands its error to `fail` and sends nothing more.
*/
class PtyWriter {
send;
fail;
queue = [];
ended = false;
wake = null;
constructor(send, fail) {
this.send = send;
this.fail = fail;
void this.loop();
}
write(data) {
this.queue.push(data.slice());
this.wake?.();
}
end() {
this.ended = true;
this.wake?.();
}
async loop() {
for (;;) {
if (this.queue.length === 0) {
if (this.ended)
return;
await new Promise((resolve) => {
this.wake = resolve;
});
this.wake = null;
continue;
}
const chunks = this.queue;
this.queue = [];
try {
await this.send(concat(chunks));
}
catch (err) {
await this.fail(err);
return;
}
}
}
}
/**
* An E2B sandbox the user runs as a whole-line runtime.
*
* You create the sandbox yourself (`e2b sandbox spawn` or the SDK);
* mirage only connects by `sandboxId` and execs lines. `apiKey` falls
* back to E2B_API_KEY. E2B's exec reports stdout and stderr
* separately, stdout as it arrives when streamed. Piped bytes use native
* stdin, chunk by chunk, followed by an explicit EOF; no input closes
* stdin when the command starts. The SDK's own command timeout is off: a
* command runs until mirage's limits stop it. A `mountType` serves the
* workspace over a PTY, since E2B's exec decodes output as text.
*/
export class E2BRuntime extends RemoteSandbox {
[PIPE_TRANSPORT] = true;
name = 'e2b';
sdk = null;
sandbox = null;
constructor(options = {}) {
super(options, E2B_CONFIG_KEYS);
requireId('e2b', 'sandboxId', this.config.sandboxId);
}
// The SDK loader as a seam: tests substitute a fake module here.
loadSdk() {
return loadSdk();
}
async ensureSdk() {
this.sdk ??= await this.loadSdk();
return this.sdk;
}
apiParams() {
return this.config.apiKey !== undefined ? { apiKey: this.config.apiKey } : {};
}
async connect() {
const sdk = await this.ensureSdk();
this.sandbox = await sdk.Sandbox.connect(this.config.sandboxId, this.apiParams());
}
async execArgv(argv, stdin, env, cwd, output, errors, signal) {
signal?.throwIfAborted();
if (this.sandbox === null)
throw new Error('e2b sandbox not connected');
const runner = currentCaller().process;
const sdk = await this.ensureSdk();
signal?.throwIfAborted();
// Keep the startup result even if aborted in flight, so its process can be killed.
const handle = await this.sandbox.commands.run(wrapLine(argv, env, cwd), {
background: true,
stdin: stdin !== null,
timeoutMs: 0,
...(output !== null ? { onStdout: (text) => output(ENC.encode(text)) } : {}),
...(errors !== null ? { onStderr: (text) => errors(ENC.encode(text)) } : {}),
});
let result;
const state = { exited: false };
const write = async (chunk) => {
if (chunk.byteLength > 0)
await abortable(handle.sendStdin(chunk), signal);
};
const end = async () => {
if (stdin !== null)
await abortable(handle.closeStdin(), signal);
};
// The command may exit before the input RPC arrives, or close its
// input while it runs (head). Wait for its real exit status rather
// than reporting either as I/O.
const closed = (error) => (error instanceof sdk.SandboxError &&
CLOSED_INPUT.some((message) => error.message.includes(message))) ||
// Process RPCs still throw this SDK class; no process-specific replacement exists.
// eslint-disable-next-line @typescript-eslint/no-deprecated
error instanceof sdk.NotFoundError;
try {
signal?.throwIfAborted();
// The command may end without reading all its input (head), so its
// exit, not the end of the input, ends the line: a pull still waiting
// on input that stays open is left to stop at its next chunk.
const feeding = feed(stdin, write, end, () => state.exited, closed);
feeding.catch((error) => {
if (state.exited)
console.debug(`e2b input after the command ended: ${String(error)}`);
});
const finished = abortable(handle.wait(), signal);
try {
result = await Promise.race([finished, feeding.then(() => finished)]);
}
finally {
state.exited = true;
}
signal?.throwIfAborted();
}
catch (error) {
if (!signal?.aborted && error instanceof sdk.CommandExitError) {
result = error;
}
else {
try {
// A tagged command a kill stopped with a gentler signal is left
// to the reap, which sends it that signal first.
if (env[EXEC_ID] === undefined || stopSignal(runner) === null)
await handle.kill();
}
catch (cleanupError) {
console.warn('Failed to stop the E2B command', cleanupError);
}
if (signal?.aborted)
throw new DOMException('execute aborted', 'AbortError');
throw error;
}
}
finally {
await handle.disconnect();
}
return {
stdout: output === null ? ENC.encode(result.stdout) : new Uint8Array(),
stderr: errors === null ? ENC.encode(result.stderr) : new Uint8Array(),
exitCode: result.exitCode,
};
}
async openPipe(command) {
if (this.sandbox === null)
throw new Error('e2b sandbox not connected');
const pty = this.sandbox.pty;
let feed = null;
const reader = new ReadableStream({
start(controller) {
feed = controller;
},
});
let head = new Uint8Array(0);
const onData = (data) => {
if (head === null) {
feed?.enqueue(data.slice());
return;
}
head = concat([head, data]);
const past = pastMark(head);
if (past < 0)
return;
const rest = head.slice(past);
head = null;
if (rest.byteLength > 0)
feed?.enqueue(rest);
};
const handle = await pty.create({ cols: 80, rows: 24, onData, timeoutMs: 0 });
let failed = false;
const fail = async (err) => {
failed = true;
feed?.error(err);
await stop().catch((stopErr) => {
console.debug(`e2b: stopping the PTY for ${command} failed`, stopErr);
});
};
const writer = new PtyWriter((data) => pty.sendInput(handle.pid, data), fail);
let ended = false;
const waiting = handle
.wait()
.then(() => undefined, (err) => {
console.debug(`e2b: the PTY for ${command} ended`, err);
})
.finally(() => {
ended = true;
if (!failed)
feed?.close();
writer.end();
});
const stop = async () => {
if (!ended)
await pty.kill(handle.pid);
await waiting;
};
try {
await pty.sendInput(handle.pid, ENC.encode(`${PTY_RAW}${command}\n`));
}
catch (err) {
await stop().catch((stopErr) => {
console.debug(`e2b: stopping the PTY for ${command} failed`, stopErr);
});
throw err;
}
return { reader, writer, stop };
}
}
registerRuntime('e2b', E2BRuntime);
//# sourceMappingURL=runtime.js.map