mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
Add E2B sandbox provider plugin (#4452)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Sandbox environments are part of that execution layer, and the recent core refactor moved provider-specific behavior to a generic plugin seam > - This pull request adds a dedicated `@paperclipai/plugin-e2b` package so E2B can live entirely outside core host code > - Because the feature is still unreleased, the plugin should model third-party packaging directly instead of carrying extra backward-compatibility complexity in core or the workspace lockfile > - This branch therefore makes the E2B provider a standalone publishable package, documents the package-local dev flow, and keeps the publish manifest/runtime dependency story correct > - The benefit is that E2B becomes a true plugin reference implementation that can be installed by package name without reopening core Paperclip code ## What Changed - Added `packages/plugins/paperclip-plugin-e2b` as the E2B sandbox provider plugin package - Implemented config validation, lease acquire/resume/release/destroy handlers, workspace realization, and command execution for E2B sandboxes - Excluded the E2B plugin package from the root workspace so the repo no longer needs `pnpm-lock.yaml` churn for its third-party dependency graph - Added package-local development/install support plus a prepack manifest generator so the published tarball still declares `@paperclipai/plugin-sdk` and `e2b` runtime dependencies - Addressed review feedback by fixing sandbox cleanup on acquire failures, rejecting blank templates, normalizing fractional `timeoutMs`, and always passing the configured template name to the E2B SDK - Updated focused Vitest coverage for config normalization, validation, acquire cleanup, command execution, and lease release behavior - Updated the Dockerfile deps stage to copy the E2B package manifest so the policy check stays in sync ## Verification - `cd packages/plugins/paperclip-plugin-e2b && pnpm install --ignore-workspace --no-lockfile` - `cd packages/plugins/paperclip-plugin-e2b && pnpm build` - `cd packages/plugins/paperclip-plugin-e2b && pnpm --ignore-workspace test` - `cd packages/plugins/paperclip-plugin-e2b && pnpm --ignore-workspace typecheck` - `cd packages/plugins/paperclip-plugin-e2b && npm pack --dry-run` ## Risks - The package now relies on a prepack manifest rewrite so the publish-time dependency list stays correct while the repo-local dev manifest stays workspace-light - The current repo snapshot is still unreleased, so the generated publish manifest points at the repo SDK version until the normal release flow rewrites versions before publish - Real-world E2B environments may still expose edge cases around lifecycle timing or sandbox metadata beyond the mocked unit coverage > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex via `codex_local` - Model ID: `gpt-5.4` - Reasoning effort: `high` - Context window observed in runtime session metadata: `258400` tokens - Capabilities used: terminal tool execution, git, GitHub CLI, and local build/test inspection ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
5bd0f578fd
commit
4ef969f084
16 changed files with 1279 additions and 38 deletions
53
packages/plugins/sandbox-providers/e2b/src/e2b.d.ts
vendored
Normal file
53
packages/plugins/sandbox-providers/e2b/src/e2b.d.ts
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
declare module "e2b" {
|
||||
export class CommandExitError extends Error {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export class SandboxNotFoundError extends Error {}
|
||||
export class TimeoutError extends Error {}
|
||||
|
||||
export interface SandboxRunResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface SandboxBackgroundHandle {
|
||||
pid: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
wait(): Promise<SandboxRunResult>;
|
||||
}
|
||||
|
||||
export class Sandbox {
|
||||
sandboxId: string;
|
||||
sandboxDomain?: string;
|
||||
static create(
|
||||
templateOrOptions?: string | Record<string, unknown>,
|
||||
maybeOptions?: Record<string, unknown>,
|
||||
): Promise<Sandbox>;
|
||||
static connect(
|
||||
sandboxId: string,
|
||||
options?: Record<string, unknown>,
|
||||
): Promise<Sandbox>;
|
||||
setTimeout(timeoutMs: number): Promise<void>;
|
||||
kill(): Promise<void>;
|
||||
pause(): Promise<void>;
|
||||
commands: {
|
||||
run(
|
||||
command: string,
|
||||
options?: {
|
||||
background?: boolean;
|
||||
stdin?: boolean;
|
||||
cwd?: string;
|
||||
envs?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<SandboxRunResult | SandboxBackgroundHandle>;
|
||||
sendStdin(pid: number, input: string): Promise<void>;
|
||||
closeStdin(pid: number): Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
2
packages/plugins/sandbox-providers/e2b/src/index.ts
Normal file
2
packages/plugins/sandbox-providers/e2b/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as manifest } from "./manifest.js";
|
||||
export { default as plugin } from "./plugin.js";
|
||||
57
packages/plugins/sandbox-providers/e2b/src/manifest.ts
Normal file
57
packages/plugins/sandbox-providers/e2b/src/manifest.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.e2b-sandbox-provider";
|
||||
const PLUGIN_VERSION = "0.1.0";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "E2B Sandbox Provider",
|
||||
description:
|
||||
"First-party sandbox provider plugin that provisions E2B cloud sandboxes as Paperclip execution environments.",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
},
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "e2b",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "E2B Cloud Sandbox",
|
||||
description:
|
||||
"Provisions E2B cloud sandboxes with configurable templates, timeouts, and lease reuse.",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
template: {
|
||||
type: "string",
|
||||
description: "E2B sandbox template name.",
|
||||
default: "base",
|
||||
},
|
||||
apiKey: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
description:
|
||||
"Paperclip secret reference for the E2B API key. Falls back to E2B_API_KEY if omitted.",
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "number",
|
||||
description: "Sandbox timeout in milliseconds.",
|
||||
default: 300000,
|
||||
},
|
||||
reuseLease: {
|
||||
type: "boolean",
|
||||
description: "Whether to pause and reuse sandboxes across runs.",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: ["template"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
415
packages/plugins/sandbox-providers/e2b/src/plugin.test.ts
Normal file
415
packages/plugins/sandbox-providers/e2b/src/plugin.test.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockCreate = vi.hoisted(() => vi.fn());
|
||||
const mockConnect = vi.hoisted(() => vi.fn());
|
||||
const { MockCommandExitError, MockSandboxNotFoundError, MockTimeoutError } = vi.hoisted(() => {
|
||||
class MockCommandExitError extends Error {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
|
||||
constructor(result: { exitCode: number; stdout: string; stderr: string }) {
|
||||
super("command failed");
|
||||
this.exitCode = result.exitCode;
|
||||
this.stdout = result.stdout;
|
||||
this.stderr = result.stderr;
|
||||
}
|
||||
}
|
||||
class MockSandboxNotFoundError extends Error {}
|
||||
class MockTimeoutError extends Error {}
|
||||
return { MockCommandExitError, MockSandboxNotFoundError, MockTimeoutError };
|
||||
});
|
||||
|
||||
vi.mock("e2b", () => ({
|
||||
CommandExitError: MockCommandExitError,
|
||||
SandboxNotFoundError: MockSandboxNotFoundError,
|
||||
TimeoutError: MockTimeoutError,
|
||||
Sandbox: {
|
||||
create: mockCreate,
|
||||
connect: mockConnect,
|
||||
},
|
||||
}));
|
||||
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
function createMockSandbox(overrides: {
|
||||
sandboxId?: string;
|
||||
sandboxDomain?: string;
|
||||
pwd?: string;
|
||||
waitResult?: { exitCode: number; stdout: string; stderr: string };
|
||||
} = {}) {
|
||||
const handle = {
|
||||
pid: 42,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
wait: vi.fn().mockResolvedValue(overrides.waitResult ?? {
|
||||
exitCode: 0,
|
||||
stdout: "ok\n",
|
||||
stderr: "",
|
||||
}),
|
||||
};
|
||||
return {
|
||||
sandboxId: overrides.sandboxId ?? "sandbox-123",
|
||||
sandboxDomain: overrides.sandboxDomain ?? "sandbox.example.test",
|
||||
setTimeout: vi.fn().mockResolvedValue(undefined),
|
||||
kill: vi.fn().mockResolvedValue(undefined),
|
||||
pause: vi.fn().mockResolvedValue(undefined),
|
||||
commands: {
|
||||
run: vi.fn(async (command: string, options?: { background?: boolean }) => {
|
||||
if (options?.background) return handle;
|
||||
if (command === "pwd") {
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: `${overrides.pwd ?? "/home/user"}\n`,
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
}),
|
||||
sendStdin: vi.fn().mockResolvedValue(undefined),
|
||||
closeStdin: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
handle,
|
||||
};
|
||||
}
|
||||
|
||||
describe("E2B sandbox provider plugin", () => {
|
||||
beforeEach(() => {
|
||||
mockCreate.mockReset();
|
||||
mockConnect.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.E2B_API_KEY;
|
||||
});
|
||||
|
||||
it("declares environment lifecycle handlers", async () => {
|
||||
expect(await plugin.definition.onHealth?.()).toEqual({
|
||||
status: "ok",
|
||||
message: "E2B sandbox provider plugin healthy",
|
||||
});
|
||||
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("normalizes E2B config through the generic provider shape", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "e2b",
|
||||
config: {
|
||||
template: " base ",
|
||||
apiKey: " e2b_test_key ",
|
||||
timeoutMs: "450000.9",
|
||||
reuseLease: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
normalizedConfig: {
|
||||
template: "base",
|
||||
apiKey: "e2b_test_key",
|
||||
timeoutMs: 450000,
|
||||
reuseLease: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty template strings instead of silently normalizing them", async () => {
|
||||
await expect(plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "e2b",
|
||||
config: {
|
||||
template: " ",
|
||||
},
|
||||
})).resolves.toEqual({
|
||||
ok: false,
|
||||
errors: ["E2B sandbox environments require a template."],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses resolved config keys before falling back to E2B_API_KEY", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
mockCreate.mockResolvedValue(sandbox);
|
||||
process.env.E2B_API_KEY = "host-key";
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith("base", expect.objectContaining({
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 300000,
|
||||
}));
|
||||
expect(lease).toMatchObject({
|
||||
providerLeaseId: "sandbox-123",
|
||||
metadata: {
|
||||
provider: "e2b",
|
||||
remoteCwd: "/home/user/paperclip-workspace",
|
||||
},
|
||||
});
|
||||
expect(sandbox.commands.run).toHaveBeenNthCalledWith(1, "pwd");
|
||||
expect(sandbox.commands.run).toHaveBeenNthCalledWith(2, "mkdir -p '/home/user/paperclip-workspace'");
|
||||
});
|
||||
|
||||
it("kills the sandbox if acquire setup fails after creation", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
const failure = new Error("set-timeout failed");
|
||||
sandbox.setTimeout.mockRejectedValueOnce(failure);
|
||||
mockCreate.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
},
|
||||
})).rejects.toThrow("set-timeout failed");
|
||||
|
||||
expect(sandbox.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to host E2B_API_KEY when config omits the API key", async () => {
|
||||
process.env.E2B_API_KEY = "host-key";
|
||||
const sandbox = createMockSandbox();
|
||||
mockCreate.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: null,
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
providerLeaseId: "sandbox-123",
|
||||
});
|
||||
expect(mockCreate).toHaveBeenCalledWith("base", expect.objectContaining({ apiKey: "host-key" }));
|
||||
});
|
||||
|
||||
it("kills the sandbox if resume setup fails after reconnect", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
const failure = new Error("set-timeout failed");
|
||||
sandbox.setTimeout.mockRejectedValueOnce(failure);
|
||||
mockConnect.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(plugin.definition.onEnvironmentResumeLease?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
providerLeaseId: "sandbox-123",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
},
|
||||
})).rejects.toThrow("set-timeout failed");
|
||||
|
||||
expect(sandbox.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executes commands through a connected sandbox", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
mockConnect.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
},
|
||||
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
cwd: "/workspace",
|
||||
env: { FOO: "bar" },
|
||||
stdin: "input",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(mockConnect).toHaveBeenCalledWith("sandbox-123", expect.objectContaining({ apiKey: "resolved-key" }));
|
||||
expect(sandbox.commands.run).toHaveBeenCalledWith("exec 'printf' 'hello'", expect.objectContaining({
|
||||
background: true,
|
||||
cwd: "/workspace",
|
||||
envs: { FOO: "bar" },
|
||||
stdin: true,
|
||||
timeoutMs: 1000,
|
||||
}));
|
||||
expect(sandbox.commands.sendStdin).toHaveBeenCalledWith(42, "input");
|
||||
expect(sandbox.commands.closeStdin).toHaveBeenCalledWith(42);
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
stdout: "ok\n",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("closes stdin even when sendStdin throws unexpectedly", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
const failure = new Error("send failed");
|
||||
sandbox.commands.sendStdin.mockRejectedValueOnce(failure);
|
||||
mockConnect.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
},
|
||||
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
cwd: "/workspace",
|
||||
env: { FOO: "bar" },
|
||||
stdin: "input",
|
||||
timeoutMs: 1000,
|
||||
})).rejects.toThrow("send failed");
|
||||
|
||||
expect(sandbox.commands.closeStdin).toHaveBeenCalledWith(42);
|
||||
expect(sandbox.handle.wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pauses reusable leases and kills ephemeral leases on release", async () => {
|
||||
const reusable = createMockSandbox({ sandboxId: "sandbox-reusable" });
|
||||
const ephemeral = createMockSandbox({ sandboxId: "sandbox-ephemeral" });
|
||||
mockConnect.mockResolvedValueOnce(reusable).mockResolvedValueOnce(ephemeral);
|
||||
|
||||
await plugin.definition.onEnvironmentReleaseLease?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 300000,
|
||||
reuseLease: true,
|
||||
},
|
||||
providerLeaseId: "sandbox-reusable",
|
||||
});
|
||||
await plugin.definition.onEnvironmentReleaseLease?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
},
|
||||
providerLeaseId: "sandbox-ephemeral",
|
||||
});
|
||||
|
||||
expect(reusable.pause).toHaveBeenCalled();
|
||||
expect(reusable.kill).not.toHaveBeenCalled();
|
||||
expect(ephemeral.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to kill when pausing a reusable lease fails", async () => {
|
||||
const sandbox = createMockSandbox({ sandboxId: "sandbox-reusable" });
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
sandbox.pause.mockRejectedValueOnce(new Error("pause failed"));
|
||||
mockConnect.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(plugin.definition.onEnvironmentReleaseLease?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 300000,
|
||||
reuseLease: true,
|
||||
},
|
||||
providerLeaseId: "sandbox-reusable",
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(sandbox.pause).toHaveBeenCalled();
|
||||
expect(sandbox.kill).toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates the remote workspace before returning it", async () => {
|
||||
const sandbox = createMockSandbox({ sandboxId: "sandbox-realize" });
|
||||
mockConnect.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(plugin.definition.onEnvironmentRealizeWorkspace?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
},
|
||||
lease: {
|
||||
providerLeaseId: "sandbox-realize",
|
||||
metadata: { remoteCwd: "/home/user/paperclip-workspace" },
|
||||
},
|
||||
workspace: {
|
||||
localPath: "/tmp/paperclip-workspace",
|
||||
},
|
||||
})).resolves.toEqual({
|
||||
cwd: "/home/user/paperclip-workspace",
|
||||
metadata: {
|
||||
provider: "e2b",
|
||||
remoteCwd: "/home/user/paperclip-workspace",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockConnect).toHaveBeenCalledWith("sandbox-realize", expect.objectContaining({ apiKey: "resolved-key" }));
|
||||
expect(sandbox.commands.run).toHaveBeenCalledWith("mkdir -p '/home/user/paperclip-workspace'");
|
||||
});
|
||||
|
||||
it("swallows destroy kill errors after logging them", async () => {
|
||||
const sandbox = createMockSandbox({ sandboxId: "sandbox-destroy" });
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
sandbox.kill.mockRejectedValueOnce(new Error("kill failed"));
|
||||
mockConnect.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(plugin.definition.onEnvironmentDestroyLease?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
},
|
||||
providerLeaseId: "sandbox-destroy",
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(sandbox.kill).toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
371
packages/plugins/sandbox-providers/e2b/src/plugin.ts
Normal file
371
packages/plugins/sandbox-providers/e2b/src/plugin.ts
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import path from "node:path";
|
||||
import { CommandExitError, Sandbox, SandboxNotFoundError, TimeoutError } from "e2b";
|
||||
import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginEnvironmentLease,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginEnvironmentProbeResult,
|
||||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentValidationResult,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
|
||||
interface E2bDriverConfig {
|
||||
template: string;
|
||||
apiKey: string | null;
|
||||
timeoutMs: number;
|
||||
reuseLease: boolean;
|
||||
}
|
||||
|
||||
function parseDriverConfig(raw: Record<string, unknown>): E2bDriverConfig {
|
||||
const template = typeof raw.template === "string" && raw.template.trim().length > 0
|
||||
? raw.template.trim()
|
||||
: "base";
|
||||
const timeoutMs = Number(raw.timeoutMs ?? 300_000);
|
||||
return {
|
||||
template,
|
||||
apiKey: typeof raw.apiKey === "string" && raw.apiKey.trim().length > 0 ? raw.apiKey.trim() : null,
|
||||
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 300_000,
|
||||
reuseLease: raw.reuseLease === true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveApiKey(config: E2bDriverConfig): string {
|
||||
if (config.apiKey) {
|
||||
return config.apiKey;
|
||||
}
|
||||
const envApiKey = process.env.E2B_API_KEY?.trim() ?? "";
|
||||
if (!envApiKey) {
|
||||
throw new Error("E2B sandbox environments require an API key in config or E2B_API_KEY.");
|
||||
}
|
||||
return envApiKey;
|
||||
}
|
||||
|
||||
async function createSandbox(config: E2bDriverConfig): Promise<Sandbox> {
|
||||
const options = {
|
||||
apiKey: resolveApiKey(config),
|
||||
timeoutMs: config.timeoutMs,
|
||||
metadata: {
|
||||
paperclipProvider: "e2b",
|
||||
},
|
||||
};
|
||||
return await Sandbox.create(config.template, options);
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function ensureSandboxWorkspace(sandbox: Sandbox, remoteCwd: string): Promise<void> {
|
||||
await sandbox.commands.run(`mkdir -p ${shellQuote(remoteCwd)}`);
|
||||
}
|
||||
|
||||
async function resolveSandboxWorkingDirectory(sandbox: Sandbox): Promise<string> {
|
||||
const result = await sandbox.commands.run("pwd");
|
||||
const cwd = result.stdout.trim();
|
||||
const remoteCwd = path.posix.join(cwd.length > 0 ? cwd : "/", "paperclip-workspace");
|
||||
await ensureSandboxWorkspace(sandbox, remoteCwd);
|
||||
return remoteCwd;
|
||||
}
|
||||
|
||||
async function connectSandbox(config: E2bDriverConfig, providerLeaseId: string): Promise<Sandbox> {
|
||||
return await Sandbox.connect(providerLeaseId, {
|
||||
apiKey: resolveApiKey(config),
|
||||
timeoutMs: config.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
async function connectForCleanup(config: E2bDriverConfig, providerLeaseId: string): Promise<Sandbox | null> {
|
||||
try {
|
||||
return await connectSandbox(config, providerLeaseId);
|
||||
} catch (error) {
|
||||
if (error instanceof SandboxNotFoundError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function leaseMetadata(input: {
|
||||
config: E2bDriverConfig;
|
||||
sandbox: Sandbox;
|
||||
remoteCwd: string;
|
||||
resumedLease: boolean;
|
||||
}) {
|
||||
return {
|
||||
provider: "e2b",
|
||||
template: input.config.template,
|
||||
timeoutMs: input.config.timeoutMs,
|
||||
reuseLease: input.config.reuseLease,
|
||||
sandboxId: input.sandbox.sandboxId,
|
||||
sandboxDomain: input.sandbox.sandboxDomain,
|
||||
remoteCwd: input.remoteCwd,
|
||||
resumedLease: input.resumedLease,
|
||||
};
|
||||
}
|
||||
|
||||
function shellQuote(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildCommandLine(command: string, args: string[] = []) {
|
||||
return `exec ${[command, ...args].map(shellQuote).join(" ")}`;
|
||||
}
|
||||
|
||||
async function killSandboxBestEffort(sandbox: Sandbox, reason: string): Promise<void> {
|
||||
await sandbox.kill().catch((error) => {
|
||||
console.warn(`Failed to kill E2B sandbox during ${reason}: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function releaseSandboxBestEffort(sandbox: Sandbox, reuseLease: boolean): Promise<void> {
|
||||
if (!reuseLease) {
|
||||
await killSandboxBestEffort(sandbox, "lease release");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sandbox.pause();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to pause E2B sandbox during lease release: ${formatErrorMessage(error)}. Attempting kill instead.`,
|
||||
);
|
||||
await killSandboxBestEffort(sandbox, "lease release fallback cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info("E2B sandbox provider plugin ready");
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "E2B sandbox provider plugin healthy" };
|
||||
},
|
||||
|
||||
async onEnvironmentValidateConfig(
|
||||
params: PluginEnvironmentValidateConfigParams,
|
||||
): Promise<PluginEnvironmentValidationResult> {
|
||||
const config = parseDriverConfig(params.config);
|
||||
const errors: string[] = [];
|
||||
|
||||
if (typeof params.config.template === "string" && params.config.template.trim().length === 0) {
|
||||
errors.push("E2B sandbox environments require a template.");
|
||||
}
|
||||
if (config.timeoutMs < 1 || config.timeoutMs > 86_400_000) {
|
||||
errors.push("timeoutMs must be between 1 and 86400000.");
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
normalizedConfig: { ...config },
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentProbe(
|
||||
params: PluginEnvironmentProbeParams,
|
||||
): Promise<PluginEnvironmentProbeResult> {
|
||||
const config = parseDriverConfig(params.config);
|
||||
try {
|
||||
const sandbox = await createSandbox(config);
|
||||
try {
|
||||
await sandbox.setTimeout(config.timeoutMs);
|
||||
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Connected to E2B sandbox template ${config.template}.`,
|
||||
metadata: {
|
||||
provider: "e2b",
|
||||
template: config.template,
|
||||
timeoutMs: config.timeoutMs,
|
||||
reuseLease: config.reuseLease,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
sandboxDomain: sandbox.sandboxDomain,
|
||||
remoteCwd,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await sandbox.kill().catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
summary: `E2B sandbox probe failed for template ${config.template}.`,
|
||||
metadata: {
|
||||
provider: "e2b",
|
||||
template: config.template,
|
||||
timeoutMs: config.timeoutMs,
|
||||
reuseLease: config.reuseLease,
|
||||
error: message,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentAcquireLease(
|
||||
params: PluginEnvironmentAcquireLeaseParams,
|
||||
): Promise<PluginEnvironmentLease> {
|
||||
const config = parseDriverConfig(params.config);
|
||||
const sandbox = await createSandbox(config);
|
||||
try {
|
||||
await sandbox.setTimeout(config.timeoutMs);
|
||||
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
||||
|
||||
return {
|
||||
providerLeaseId: sandbox.sandboxId,
|
||||
metadata: leaseMetadata({ config, sandbox, remoteCwd, resumedLease: false }),
|
||||
};
|
||||
} catch (error) {
|
||||
await sandbox.kill().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentResumeLease(
|
||||
params: PluginEnvironmentResumeLeaseParams,
|
||||
): Promise<PluginEnvironmentLease> {
|
||||
const config = parseDriverConfig(params.config);
|
||||
try {
|
||||
const sandbox = await connectSandbox(config, params.providerLeaseId);
|
||||
try {
|
||||
await sandbox.setTimeout(config.timeoutMs);
|
||||
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
||||
|
||||
return {
|
||||
providerLeaseId: sandbox.sandboxId,
|
||||
metadata: leaseMetadata({ config, sandbox, remoteCwd, resumedLease: true }),
|
||||
};
|
||||
} catch (error) {
|
||||
await sandbox.kill().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SandboxNotFoundError) {
|
||||
return { providerLeaseId: null, metadata: { expired: true } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentReleaseLease(
|
||||
params: PluginEnvironmentReleaseLeaseParams,
|
||||
): Promise<void> {
|
||||
if (!params.providerLeaseId) return;
|
||||
const config = parseDriverConfig(params.config);
|
||||
const sandbox = await connectForCleanup(config, params.providerLeaseId);
|
||||
if (!sandbox) return;
|
||||
|
||||
await releaseSandboxBestEffort(sandbox, config.reuseLease);
|
||||
},
|
||||
|
||||
async onEnvironmentDestroyLease(
|
||||
params: PluginEnvironmentDestroyLeaseParams,
|
||||
): Promise<void> {
|
||||
if (!params.providerLeaseId) return;
|
||||
const config = parseDriverConfig(params.config);
|
||||
const sandbox = await connectForCleanup(config, params.providerLeaseId);
|
||||
if (!sandbox) return;
|
||||
await killSandboxBestEffort(sandbox, "lease destroy");
|
||||
},
|
||||
|
||||
async onEnvironmentRealizeWorkspace(
|
||||
params: PluginEnvironmentRealizeWorkspaceParams,
|
||||
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
|
||||
const config = parseDriverConfig(params.config);
|
||||
const remoteCwd =
|
||||
typeof params.lease.metadata?.remoteCwd === "string" &&
|
||||
params.lease.metadata.remoteCwd.trim().length > 0
|
||||
? params.lease.metadata.remoteCwd.trim()
|
||||
: params.workspace.remotePath ?? params.workspace.localPath ?? "/paperclip-workspace";
|
||||
|
||||
if (params.lease.providerLeaseId) {
|
||||
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
|
||||
await ensureSandboxWorkspace(sandbox, remoteCwd);
|
||||
}
|
||||
|
||||
return {
|
||||
cwd: remoteCwd,
|
||||
metadata: {
|
||||
provider: "e2b",
|
||||
remoteCwd,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentExecute(
|
||||
params: PluginEnvironmentExecuteParams,
|
||||
): Promise<PluginEnvironmentExecuteResult> {
|
||||
if (!params.lease.providerLeaseId) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "No provider lease ID available for execution.",
|
||||
};
|
||||
}
|
||||
|
||||
const config = parseDriverConfig(params.config);
|
||||
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
|
||||
const started = await sandbox.commands.run(buildCommandLine(params.command, params.args), {
|
||||
background: true,
|
||||
stdin: params.stdin != null,
|
||||
cwd: params.cwd,
|
||||
envs: params.env,
|
||||
timeoutMs: params.timeoutMs ?? config.timeoutMs,
|
||||
}) as Awaited<ReturnType<Sandbox["commands"]["run"]>> & {
|
||||
pid: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
wait(): Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
||||
};
|
||||
|
||||
try {
|
||||
if (params.stdin != null) {
|
||||
try {
|
||||
await sandbox.commands.sendStdin(started.pid, params.stdin);
|
||||
} finally {
|
||||
await sandbox.commands.closeStdin(started.pid);
|
||||
}
|
||||
}
|
||||
const result = await started.wait();
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
timedOut: false,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof CommandExitError) {
|
||||
const commandError = error as CommandExitError;
|
||||
return {
|
||||
exitCode: commandError.exitCode,
|
||||
timedOut: false,
|
||||
stdout: commandError.stdout,
|
||||
stderr: commandError.stderr,
|
||||
};
|
||||
}
|
||||
if (error instanceof TimeoutError) {
|
||||
const timeoutError = error as TimeoutError;
|
||||
return {
|
||||
exitCode: null,
|
||||
timedOut: true,
|
||||
stdout: started.stdout,
|
||||
stderr: started.stderr || `${timeoutError.message}\n`,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
5
packages/plugins/sandbox-providers/e2b/src/worker.ts
Normal file
5
packages/plugins/sandbox-providers/e2b/src/worker.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { runWorker } from "@paperclipai/plugin-sdk";
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
Loading…
Add table
Add a link
Reference in a new issue