mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
Harden E2B plugin lifecycle handling
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user