Harden E2B plugin lifecycle handling

This commit is contained in:
Devin Foley
2026-04-24 22:08:42 -07:00
parent d9a9f4f7ab
commit 351552d875
4 changed files with 139 additions and 10 deletions

View File

@@ -57,9 +57,16 @@ function createMockSandbox(overrides: {
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: `${overrides.pwd ?? "/home/user"}\n`,
stdout: "",
stderr: "",
};
}),
@@ -74,6 +81,7 @@ describe("E2B sandbox provider plugin", () => {
beforeEach(() => {
mockCreate.mockReset();
mockConnect.mockReset();
vi.restoreAllMocks();
delete process.env.E2B_API_KEY;
});
@@ -149,6 +157,8 @@ describe("E2B sandbox provider plugin", () => {
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 () => {
@@ -299,4 +309,84 @@ describe("E2B sandbox provider plugin", () => {
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

@@ -59,10 +59,20 @@ async function createSandbox(config: E2bDriverConfig): Promise<Sandbox> {
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();
return path.posix.join(cwd.length > 0 ? cwd : "/", "paperclip-workspace");
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> {
@@ -107,6 +117,28 @@ 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");
@@ -228,11 +260,7 @@ const plugin = definePlugin({
const sandbox = await connectForCleanup(config, params.providerLeaseId);
if (!sandbox) return;
if (config.reuseLease) {
await sandbox.pause();
} else {
await sandbox.kill();
}
await releaseSandboxBestEffort(sandbox, config.reuseLease);
},
async onEnvironmentDestroyLease(
@@ -241,18 +269,25 @@ const plugin = definePlugin({
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
const sandbox = await connectForCleanup(config, params.providerLeaseId);
await sandbox?.kill();
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: {

View File

@@ -12,6 +12,7 @@ const sdkPackageJsonPath = join(repoRoot, "packages", "plugins", "sdk", "package
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
const sdkPackageJson = JSON.parse(readFileSync(sdkPackageJsonPath, "utf8"));
const publishConfig = packageJson.publishConfig ?? {};
const publishPackageJson = {
name: packageJson.name,
@@ -22,8 +23,10 @@ const publishPackageJson = {
bugs: packageJson.bugs,
repository: packageJson.repository,
type: packageJson.type,
exports: packageJson.exports,
publishConfig: packageJson.publishConfig,
exports: publishConfig.exports ?? packageJson.exports,
main: publishConfig.main,
types: publishConfig.types,
publishConfig,
files: packageJson.files,
paperclipPlugin: packageJson.paperclipPlugin,
keywords: packageJson.keywords,

View File

@@ -18,6 +18,7 @@ try {
if (stat.isSymbolicLink()) {
rmSync(linkTarget, { force: true });
} else {
console.log(" i Keeping existing installed @paperclipai/plugin-sdk directory in place");
process.exit(0);
}
} catch {