UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

267 lines • 10.2 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 { 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