mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-10 08: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
39
.github/workflows/pr.yml
vendored
39
.github/workflows/pr.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
33
packages/plugins/sandbox-providers/e2b/README.md
Normal file
33
packages/plugins/sandbox-providers/e2b/README.md
Normal 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/`
|
||||
61
packages/plugins/sandbox-providers/e2b/package.json
Normal file
61
packages/plugins/sandbox-providers/e2b/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
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);
|
||||
11
packages/plugins/sandbox-providers/e2b/tsconfig.json
Normal file
11
packages/plugins/sandbox-providers/e2b/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
8
packages/plugins/sandbox-providers/e2b/vitest.config.ts
Normal file
8
packages/plugins/sandbox-providers/e2b/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
176
scripts/check-docker-deps-stage.mjs
Normal file
176
scripts/check-docker-deps-stage.mjs
Normal 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();
|
||||
46
scripts/generate-plugin-package-json.mjs
Normal file
46
scripts/generate-plugin-package-json.mjs
Normal 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}`);
|
||||
35
scripts/link-plugin-dev-sdk.mjs
Normal file
35
scripts/link-plugin-dev-sdk.mjs
Normal 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}`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue