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:
Devin Foley 2026-04-25 11:01:11 -07:00 committed by GitHub
parent 5bd0f578fd
commit 4ef969f084
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1279 additions and 38 deletions

View file

@ -41,44 +41,7 @@ jobs:
node-version: 24
- name: Validate Dockerfile deps stage
run: |
missing=0
# Extract only the deps stage from the Dockerfile
deps_stage="$(awk '/^FROM .* AS deps$/{found=1; next} found && /^FROM /{exit} found{print}' Dockerfile)"
if [ -z "$deps_stage" ]; then
echo "::error::Could not extract deps stage from Dockerfile (expected 'FROM ... AS deps')"
exit 1
fi
# Derive workspace search roots from pnpm-workspace.yaml (exclude dev-only packages)
search_roots="$(grep '^ *- ' pnpm-workspace.yaml | sed 's/^ *- //' | sed 's/\*$//' | grep -v 'examples' | grep -v 'create-paperclip-plugin' | tr '\n' ' ')"
if [ -z "$search_roots" ]; then
echo "::error::Could not derive workspace roots from pnpm-workspace.yaml"
exit 1
fi
# Check all workspace package.json files are copied in the deps stage
for pkg in $(find $search_roots -maxdepth 2 -name package.json -not -path '*/examples/*' -not -path '*/create-paperclip-plugin/*' -not -path '*/node_modules/*' 2>/dev/null | sort -u); do
dir="$(dirname "$pkg")"
if ! echo "$deps_stage" | grep -q "^COPY ${dir}/package.json"; then
echo "::error::Dockerfile deps stage missing: COPY ${pkg} ${dir}/"
missing=1
fi
done
# Check patches directory is copied if it exists
if [ -d patches ] && ! echo "$deps_stage" | grep -q '^COPY patches/'; then
echo "::error::Dockerfile deps stage missing: COPY patches/ patches/"
missing=1
fi
if [ "$missing" -eq 1 ]; then
echo "Dockerfile deps stage is out of sync. Update it to include the missing files."
exit 1
fi
run: node ./scripts/check-docker-deps-stage.mjs
- name: Validate dependency resolution when manifests change
run: |

View file

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.20
FROM node:lts-trixie-slim AS base
ARG USER_UID=1000
ARG USER_GID=1000
@ -29,6 +30,7 @@ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
COPY patches/ patches/

View file

@ -0,0 +1,33 @@
# `@paperclipai/plugin-e2b`
Published E2B sandbox provider plugin for Paperclip.
This package lives in the Paperclip monorepo, but it is intentionally excluded from the root `pnpm` workspace and shaped to publish and install like a standalone npm package. That means operators can install it from the Plugins page by package name, and the host will fetch its transitive dependencies at install time without adding lockfile churn to the Paperclip repo.
## Install
From a Paperclip instance, install:
```text
@paperclipai/plugin-e2b
```
The host plugin installer runs `npm install` into the managed plugin directory, so package dependencies such as `e2b` are pulled in during installation.
## Local development
```bash
cd packages/plugins/sandbox-providers/e2b
pnpm install --ignore-workspace --no-lockfile
pnpm build
pnpm test
pnpm typecheck
```
These commands assume the repo root has already been installed once so the local `@paperclipai/plugin-sdk` workspace package is available to the compiler during development.
## Package layout
- `src/manifest.ts` declares the sandbox-provider driver metadata
- `src/plugin.ts` implements the environment lifecycle hooks
- `paperclipPlugin.manifest` and `paperclipPlugin.worker` point the host at the built plugin entrypoints in `dist/`

View file

@ -0,0 +1,61 @@
{
"name": "@paperclipai/plugin-e2b",
"version": "0.1.0",
"description": "E2B sandbox provider plugin for Paperclip environments",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/plugins/sandbox-providers/e2b"
},
"type": "module",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js"
},
"keywords": [
"paperclip",
"plugin",
"sandbox",
"e2b"
],
"scripts": {
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk build",
"build": "rm -rf dist && tsc",
"clean": "rm -rf dist",
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk build && tsc --noEmit",
"test": "vitest run --config vitest.config.ts",
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs",
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
"dependencies": {
"e2b": "^2.19.0"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
}
}

View 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>;
};
}
}

View file

@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as plugin } from "./plugin.js";

View 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;

View 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();
});
});

View 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;

View file

@ -0,0 +1,5 @@
import { runWorker } from "@paperclipai/plugin-sdk";
import plugin from "./plugin.js";
export default plugin;
runWorker(plugin, import.meta.url);

View file

@ -0,0 +1,11 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023"],
"types": ["node"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
});

View file

@ -2,6 +2,9 @@ packages:
- packages/*
- packages/adapters/*
- packages/plugins/*
# Keep sandbox-provider plugins installable as standalone packages without
# forcing root pnpm-lock.yaml churn for their third-party deps.
- "!packages/plugins/sandbox-providers/**"
- packages/plugins/examples/*
# Keep this smoke fixture installable as a local plugin example without
# forcing PRs to commit pnpm-lock.yaml for a new workspace importer.

View file

@ -0,0 +1,176 @@
#!/usr/bin/env node
import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
const repoRoot = process.cwd();
const dockerfilePath = path.join(repoRoot, "Dockerfile");
const workspacePath = path.join(repoRoot, "pnpm-workspace.yaml");
function extractDepsStage(dockerfileText) {
const lines = dockerfileText.split("\n");
const captured = [];
let inDeps = false;
for (const line of lines) {
if (!inDeps) {
if (/^FROM .* AS deps$/i.test(line.trim())) inDeps = true;
continue;
}
if (/^FROM /i.test(line.trim())) break;
captured.push(line);
}
return captured.join("\n");
}
function parseWorkspaceRoots(workspaceText) {
return workspaceText
.split("\n")
.map((line) => line.match(/^\s*-\s+(.+)\s*$/)?.[1]?.trim() ?? null)
.map((entry) => {
if (!entry) return entry;
return entry.replace(/^(['"])(.*)\1$/, "$2");
})
.filter(Boolean)
.filter((entry) => !entry.startsWith("!"))
.map((entry) => entry.replace(/\*+$/, ""))
.filter((entry) => entry.length > 0)
.filter((entry) => !entry.includes("examples"))
.filter((entry) => !entry.includes("create-paperclip-plugin"));
}
function walkPackageJsonFiles(rootRelative, maxDepth) {
const results = [];
const rootAbsolute = path.join(repoRoot, rootRelative);
if (!existsSync(rootAbsolute)) return results;
function visit(currentAbsolute, depthFromRoot) {
const entries = readdirSync(currentAbsolute, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === "node_modules") continue;
const absolute = path.join(currentAbsolute, entry.name);
const relative = path.relative(repoRoot, absolute).split(path.sep).join("/");
if (entry.isDirectory()) {
if (depthFromRoot < maxDepth) visit(absolute, depthFromRoot + 1);
continue;
}
if (
entry.name === "package.json" &&
!relative.includes("/examples/") &&
!relative.includes("/create-paperclip-plugin/")
) {
results.push(relative);
}
}
}
visit(rootAbsolute, 0);
return results;
}
function globToRegExp(pattern) {
const normalized = pattern.replace("/./", "/");
let regex = "";
for (let index = 0; index < normalized.length; index += 1) {
const char = normalized[index];
const next = normalized[index + 1];
if (char === "*" && next === "*") {
regex += ".*";
index += 1;
continue;
}
if (char === "*") {
regex += "[^/]*";
continue;
}
if (char === "?") {
regex += "[^/]";
continue;
}
regex += /[|\\{}()[\]^$+?.]/.test(char) ? `\\${char}` : char;
}
return new RegExp(`^${regex}$`);
}
function parseCopySources(depsStage) {
const sources = [];
for (const rawLine of depsStage.split("\n")) {
const line = rawLine.trim();
if (!line.startsWith("COPY ")) continue;
const tokens = line.split(/\s+/);
let index = 1;
while (tokens[index]?.startsWith("--")) index += 1;
const args = tokens.slice(index);
if (args.length < 2) continue;
const lineSources = args.slice(0, -1);
for (const source of lineSources) {
sources.push(source);
}
}
return sources;
}
function main() {
const depsStage = extractDepsStage(readFileSync(dockerfilePath, "utf8"));
if (!depsStage.trim()) {
console.error("Could not extract deps stage from Dockerfile (expected 'FROM ... AS deps').");
process.exit(1);
}
const workspaceRoots = parseWorkspaceRoots(readFileSync(workspacePath, "utf8"));
if (workspaceRoots.length === 0) {
console.error("Could not derive workspace roots from pnpm-workspace.yaml.");
process.exit(1);
}
const requiredPackageJsons = [...new Set(
workspaceRoots.flatMap((root) => walkPackageJsonFiles(root, 2)),
)].sort();
const copySources = parseCopySources(depsStage);
const copyMatchers = copySources.map((source) => ({
source,
regex: globToRegExp(source),
}));
let missing = 0;
for (const pkg of requiredPackageJsons) {
const covered = copyMatchers.some(({ regex }) => regex.test(pkg));
if (!covered) {
console.error(`Dockerfile deps stage missing package manifest coverage for: ${pkg}`);
missing = 1;
}
}
if (existsSync(path.join(repoRoot, "patches"))) {
const patchesCovered = copySources.includes("patches/");
if (!patchesCovered) {
console.error("Dockerfile deps stage missing: COPY patches/ patches/");
missing = 1;
}
}
if (missing) {
console.error("Dockerfile deps stage is out of sync. Update it to cover the missing files.");
process.exit(1);
}
console.log("PASS");
}
main();

View file

@ -0,0 +1,46 @@
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const packageDir = process.cwd();
const packageJsonPath = join(packageDir, "package.json");
const sdkPackageJsonPath = join(repoRoot, "packages", "plugins", "sdk", "package.json");
if (!existsSync(packageJsonPath)) {
throw new Error(`No package.json found in plugin directory: ${packageDir}`);
}
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
const sdkPackageJson = JSON.parse(readFileSync(sdkPackageJsonPath, "utf8"));
const publishConfig = packageJson.publishConfig ?? {};
const dependencies = {
...(packageJson.dependencies ?? {}),
"@paperclipai/plugin-sdk": sdkPackageJson.version,
};
const publishPackageJson = {
name: packageJson.name,
version: packageJson.version,
description: packageJson.description,
license: packageJson.license,
homepage: packageJson.homepage,
bugs: packageJson.bugs,
repository: packageJson.repository,
type: packageJson.type,
exports: publishConfig.exports ?? packageJson.exports,
main: publishConfig.main,
types: publishConfig.types,
publishConfig,
files: packageJson.files,
paperclipPlugin: packageJson.paperclipPlugin,
keywords: packageJson.keywords,
dependencies,
};
writeFileSync(packageJsonPath, `${JSON.stringify(publishPackageJson, null, 2)}\n`);
console.log(` ✓ Generated publishable plugin package.json for ${packageJson.name}`);

View file

@ -0,0 +1,35 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, lstatSync, rmSync, symlinkSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const packageDir = process.cwd();
const sdkDir = join(repoRoot, "packages", "plugins", "sdk");
const scopeDir = join(packageDir, "node_modules", "@paperclipai");
const linkTarget = join(scopeDir, "plugin-sdk");
if (!existsSync(join(packageDir, "package.json"))) {
throw new Error(`No package.json found in plugin directory: ${packageDir}`);
}
mkdirSync(scopeDir, { recursive: true });
try {
const stat = lstatSync(linkTarget);
if (stat.isSymbolicLink()) {
rmSync(linkTarget, { force: true });
} else {
console.log(" i Keeping existing installed @paperclipai/plugin-sdk directory in place");
process.exit(0);
}
} catch {
// target does not exist yet
}
const relativeSdkDir = relative(scopeDir, sdkDir);
symlinkSync(relativeSdkDir, linkTarget, "dir");
console.log(` ✓ Linked local @paperclipai/plugin-sdk for ${packageDir}`);