From eb2624023c26ad4487790785a7b705bf22c8e934 Mon Sep 17 00:00:00 2001 From: eouzoe Date: Sat, 21 Feb 2026 09:00:26 +0800 Subject: [PATCH 1/2] feat(isolator): add Forge microVM isolator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP bridge to forge-gateway (Rust/Axum) for deterministic execution in Firecracker microVMs. Zero external dependency — self-hosted. Implements all 6 Isolator methods via REST API. --- packages/core/src/Sandbox.ts | 3 + packages/core/src/index.ts | 1 + packages/core/src/isolators/ForgeIsolator.ts | 172 +++++++++++++++++++ packages/core/src/types.ts | 2 +- 4 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/isolators/ForgeIsolator.ts diff --git a/packages/core/src/Sandbox.ts b/packages/core/src/Sandbox.ts index 8865050..bd9b7ef 100644 --- a/packages/core/src/Sandbox.ts +++ b/packages/core/src/Sandbox.ts @@ -14,6 +14,7 @@ import { Isolator } from "./isolators/Isolator.js"; import { NoneIsolator } from "./isolators/NoneIsolator.js"; import { SrtIsolator } from "./isolators/SrtIsolator.js"; import { CloudflareContainerIsolator } from "./isolators/CloudflareContainerIsolator.js"; +import { ForgeIsolator } from "./isolators/ForgeIsolator.js"; import { SandboxError } from "./errors.js"; export class BaseSandbox implements ISandbox { @@ -39,6 +40,8 @@ export class BaseSandbox implements ISandbox { return new CloudflareContainerIsolator(runtime); case "e2b": throw new SandboxError(`Isolator "e2b" not yet implemented`); + case "forge": + return new ForgeIsolator(runtime); default: throw new SandboxError(`Unknown isolator type: ${isolatorType}`); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 35c2812..40e0989 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export { Isolator } from "./isolators/Isolator.js"; export { NoneIsolator } from "./isolators/NoneIsolator.js"; export { SrtIsolator } from "./isolators/SrtIsolator.js"; export { CloudflareContainerIsolator } from "./isolators/CloudflareContainerIsolator.js"; +export { ForgeIsolator } from "./isolators/ForgeIsolator.js"; // Re-export from @sandboxxjs/state export { diff --git a/packages/core/src/isolators/ForgeIsolator.ts b/packages/core/src/isolators/ForgeIsolator.ts new file mode 100644 index 0000000..a6529d7 --- /dev/null +++ b/packages/core/src/isolators/ForgeIsolator.ts @@ -0,0 +1,172 @@ +/** + * Forge Isolator - Executes code via HTTP calls to a Forge microVM gateway + */ + +import { ExecutionError, FileSystemError } from "../errors.js"; +import type { + EvaluateResult, + ExecuteResult, + RuntimeType, + ShellResult, +} from "../types.js"; +import { Isolator, type IsolatorOptions } from "./Isolator.js"; + +export class ForgeIsolator extends Isolator { + private readonly gatewayUrl: string; + private sandboxId: string | null = null; + + constructor(runtime: RuntimeType, gatewayUrl = "http://127.0.0.1:3456") { + super(runtime); + this.gatewayUrl = gatewayUrl; + } + + private async ensureSandbox(): Promise { + if (this.sandboxId) return this.sandboxId; + + const res = await fetch(`${this.gatewayUrl}/v1/sandbox`, { + method: "POST", + }); + if (!res.ok) { + throw new ExecutionError( + `failed to create forge sandbox: ${res.status} ${res.statusText}`, + ); + } + const body = (await res.json()) as { id: string }; + this.sandboxId = body.id; + return this.sandboxId; + } + + async shell( + command: string, + options: IsolatorOptions = {}, + ): Promise { + const id = await this.ensureSandbox(); + const startTime = Date.now(); + + const res = await fetch(`${this.gatewayUrl}/v1/sandbox/${id}/shell`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ command }), + signal: options.timeout + ? AbortSignal.timeout(options.timeout) + : undefined, + }); + + if (!res.ok) { + throw new ExecutionError( + `forge shell request failed: ${res.status} ${res.statusText}`, + ); + } + + const result = (await res.json()) as ShellResult; + return { + ...result, + executionTime: result.executionTime ?? Date.now() - startTime, + }; + } + + async execute( + code: string, + options: IsolatorOptions = {}, + ): Promise { + const id = await this.ensureSandbox(); + const startTime = Date.now(); + + const res = await fetch(`${this.gatewayUrl}/v1/sandbox/${id}/execute`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, runtime: this.runtime }), + signal: options.timeout + ? AbortSignal.timeout(options.timeout) + : undefined, + }); + + if (!res.ok) { + throw new ExecutionError( + `forge execute request failed: ${res.status} ${res.statusText}`, + ); + } + + const result = (await res.json()) as ExecuteResult; + return { + ...result, + executionTime: result.executionTime ?? Date.now() - startTime, + }; + } + + async evaluate( + expr: string, + options: IsolatorOptions = {}, + ): Promise { + const startTime = Date.now(); + let code: string; + + switch (this.runtime) { + case "node": + code = `console.log(${expr})`; + break; + case "python": + code = `print(${expr})`; + break; + default: + throw new ExecutionError( + `unsupported runtime for evaluate: ${this.runtime}`, + ); + } + + const result = await this.execute(code, options); + + if (!result.success) { + throw new ExecutionError( + result.stderr || `evaluation failed with exit code ${result.exitCode}`, + ); + } + + return { + value: result.stdout.trim(), + executionTime: result.executionTime ?? Date.now() - startTime, + }; + } + + async upload(data: Buffer, remotePath: string): Promise { + const id = await this.ensureSandbox(); + + const res = await fetch(`${this.gatewayUrl}/v1/sandbox/${id}/upload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: data.toString("base64"), path: remotePath }), + }); + + if (!res.ok) { + throw new FileSystemError( + `forge upload failed: ${res.status} ${res.statusText}`, + ); + } + } + + async download(remotePath: string): Promise { + const id = await this.ensureSandbox(); + const url = `${this.gatewayUrl}/v1/sandbox/${id}/download?path=${encodeURIComponent(remotePath)}`; + + const res = await fetch(url); + if (!res.ok) { + throw new FileSystemError( + `forge download failed: ${res.status} ${res.statusText}`, + ); + } + + return Buffer.from(await res.arrayBuffer()); + } + + async destroy(): Promise { + if (!this.sandboxId) return; + + try { + await fetch(`${this.gatewayUrl}/v1/sandbox/${this.sandboxId}`, { + method: "DELETE", + }); + } finally { + this.sandboxId = null; + } + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c11bf0e..1edc91c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -8,7 +8,7 @@ import type { WithState, StateLog } from "@sandboxxjs/state"; // Configuration Types // ============================================ -export type IsolatorType = "none" | "srt" | "cloudflare" | "e2b"; +export type IsolatorType = "none" | "srt" | "cloudflare" | "e2b" | "forge"; export type RuntimeType = "node" | "python"; export interface StateConfig { From cf66511699627c130a18fc8ca7e1cc1656052ad2 Mon Sep 17 00:00:00 2001 From: eouzoe Date: Sat, 21 Feb 2026 09:02:33 +0800 Subject: [PATCH 2/2] test(isolator): add ForgeIsolator changeset and unit tests Add .changeset/forge-isolator.md marking @sandboxxjs/core as minor bump. Add ForgeIsolator.test.ts with three bun:test cases covering default gatewayUrl, no-op destroy, and ExecutionError on gateway failure. --- .changeset/forge-isolator.md | 8 ++++ .../core/src/isolators/ForgeIsolator.test.ts | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .changeset/forge-isolator.md create mode 100644 packages/core/src/isolators/ForgeIsolator.test.ts diff --git a/.changeset/forge-isolator.md b/.changeset/forge-isolator.md new file mode 100644 index 0000000..ff30457 --- /dev/null +++ b/.changeset/forge-isolator.md @@ -0,0 +1,8 @@ +--- +"@sandboxxjs/core": minor +--- + +feat(isolator): add Forge microVM isolator + +New `ForgeIsolator` class that bridges to a Rust/Axum gateway via HTTP. +Adds "forge" to `IsolatorType` union. diff --git a/packages/core/src/isolators/ForgeIsolator.test.ts b/packages/core/src/isolators/ForgeIsolator.test.ts new file mode 100644 index 0000000..4592351 --- /dev/null +++ b/packages/core/src/isolators/ForgeIsolator.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, mock, afterEach } from "bun:test"; +import { ExecutionError } from "../errors.js"; +import { ForgeIsolator } from "./ForgeIsolator.js"; + +describe("ForgeIsolator", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe("constructor", () => { + it("constructor_noArgs_usesDefaultGatewayUrl", () => { + const isolator = new ForgeIsolator("shell"); + expect((isolator as unknown as { gatewayUrl: string }).gatewayUrl).toBe( + "http://127.0.0.1:3456", + ); + }); + }); + + describe("destroy", () => { + it("destroy_noSandboxId_resolvesWithoutError", async () => { + const isolator = new ForgeIsolator("shell"); + await expect(isolator.destroy()).resolves.toBeUndefined(); + }); + }); + + describe("shell", () => { + it("shell_gatewayUnavailable_throwsExecutionError", async () => { + const isolator = new ForgeIsolator("shell", "http://127.0.0.1:19999"); + global.fetch = mock(() => + Promise.resolve( + new Response(null, { status: 503, statusText: "Service Unavailable" }), + ), + ); + + await expect(isolator.shell("echo hello")).rejects.toThrow(ExecutionError); + }); + }); +});