Improve E2B plugin configuration UX and fix execution timeouts

Clarify E2B API key configuration in plugin settings UI. Fix plugin
environment RPC timeout propagation with 30s overhead buffer.
Deduplicate timeout resolver into plugin-environment-driver.
This commit is contained in:
Devin Foley committed 2026-04-29 16:57:46 -07:00
1 parent a4ac6ff133
commit 518639fd5f
7 files changed
+187 -7

No files matched your search

@@ -14,6 +14,14 @@ From a Paperclip instance, install:
The host plugin installer runs `npm install` into the managed plugin directory, so package dependencies such as `e2b` are pulled in during installation.
## Configuration
Configure E2B from `Company Settings -> Environments`, not from the plugin's instance settings page.
- Put the E2B API key on the sandbox environment itself.
- When you save an environment, Paperclip stores pasted API keys as company secrets.
- `E2B_API_KEY` remains an optional host-level fallback when an environment omits the key.
## Local development
```bash
@@ -35,7 +35,7 @@ const manifest: PaperclipPluginManifestV1 = {
type: "string",
format: "secret-ref",
description:
"Paperclip secret reference for the E2B API key. Falls back to E2B_API_KEY if omitted.",
"Environment-specific E2B API key. Paste a key or an existing Paperclip secret reference; saved environments store pasted values as company secrets. Falls back to E2B_API_KEY if omitted.",
},
timeoutMs: {
type: "number",
@@ -551,7 +551,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(executed.stdout).toBe("ok\n");
expect(released).toHaveLength(1);
expect(released[0]?.lease.status).toBe("released");
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.anything());
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.anything(), 31000);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.anything());
});
@@ -676,7 +676,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
args: ["ok"],
cwd: "/workspace",
env: {},
timeoutMs: 1000,
});
await environmentService(db).update(environment.id, {
@@ -692,7 +691,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
config: expect.objectContaining({
apiKey: "resolved-provider-key",
}),
}));
}), 31234);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.objectContaining({
config: expect.objectContaining({
apiKey: "resolved-provider-key",
@@ -1241,7 +1240,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
args: ["ok"],
cwd: "/workspace/project",
env: { FOO: "bar" },
}));
}), 31000);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentDestroyLease", {
driverKey: "fake-plugin",
companyId,
+5 -1
View File
@@ -36,6 +36,7 @@ import {
executePluginEnvironmentCommand,
realizePluginEnvironmentWorkspace,
resolvePluginSandboxProviderDriverByKey,
resolvePluginExecuteRpcTimeoutMs,
resumePluginEnvironmentLease,
} from "./plugin-environment-driver.js";
import { collectSecretRefPaths } from "./json-schema-secret-refs.js";
@@ -654,7 +655,10 @@ function createSandboxEnvironmentDriver(
env: input.env,
stdin: input.stdin,
timeoutMs: input.timeoutMs,
});
}, resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: input.timeoutMs,
config: sanitizedConfig,
}));
}
}
throw new Error("Sandbox driver does not support direct command execution for built-in providers.");
@@ -313,5 +313,31 @@ export async function executePluginEnvironmentCommand(input: {
workerManager: input.workerManager,
config: input.config,
});
return await input.workerManager.call(plugin.id, "environmentExecute", input.params);
return await input.workerManager.call(
plugin.id,
"environmentExecute",
input.params,
resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: input.params.timeoutMs,
config: input.config.driverConfig,
}),
);
}
const RPC_OVERHEAD_BUFFER_MS = 30_000;
export function resolvePluginExecuteRpcTimeoutMs(input: {
requestedTimeoutMs?: number;
config: Record<string, unknown>;
}): number | undefined {
let baseMs: number | undefined;
if (Number.isFinite(input.requestedTimeoutMs) && (input.requestedTimeoutMs ?? 0) > 0) {
baseMs = Math.trunc(input.requestedTimeoutMs!);
} else {
const configTimeoutMs = typeof input.config.timeoutMs === "number" ? input.config.timeoutMs : null;
if (configTimeoutMs && Number.isFinite(configTimeoutMs) && configTimeoutMs > 0) {
baseMs = Math.trunc(configTimeoutMs);
}
}
return baseMs != null ? baseMs + RPC_OVERHEAD_BUFFER_MS : undefined;
}
+125
View File
@@ -0,0 +1,125 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginSettings } from "./PluginSettings";
const mockPluginsApi = vi.hoisted(() => ({
get: vi.fn(),
health: vi.fn(),
dashboard: vi.fn(),
logs: vi.fn(),
getConfig: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
vi.mock("@/api/plugins", () => ({
pluginsApi: mockPluginsApi,
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: mockSetBreadcrumbs,
}),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" },
selectedCompanyId: "company-1",
}),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
Navigate: () => null,
useParams: () => ({ companyPrefix: "PAP", pluginId: "plugin-1" }),
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: () => null,
usePluginSlots: () => ({ slots: [] }),
}));
vi.mock("@/components/PageTabBar", () => ({
PageTabBar: () => null,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("PluginSettings", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockPluginsApi.get.mockResolvedValue({
id: "plugin-1",
pluginKey: "paperclip.e2b-sandbox-provider",
packageName: "@paperclipai/plugin-e2b",
version: "0.1.0",
status: "error",
categories: ["automation"],
manifestJson: {
displayName: "E2B Sandbox Provider",
version: "0.1.0",
description: "E2B environments for Paperclip.",
author: "Paperclip",
capabilities: ["environment.drivers.register"],
environmentDrivers: [
{
driverKey: "e2b",
kind: "sandbox_provider",
displayName: "E2B Cloud Sandbox",
},
],
},
lastError: null,
});
mockPluginsApi.dashboard.mockResolvedValue(null);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("routes environment-provider plugins to company environments when they have no instance config", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<PluginSettings />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Configure this plugin from Company Environments.");
expect(container.textContent).toContain("company-scoped instead of instance-global");
const link = container.querySelector('a[href="/company/settings/environments"]');
expect(link?.textContent).toContain("Open Company Environments");
await act(async () => {
root.unmount();
});
});
});
+18
View File
@@ -142,6 +142,11 @@ export function PluginSettings() {
: "secondary";
const pluginDescription = plugin.manifestJson.description || "No description provided.";
const pluginCapabilities = plugin.manifestJson.capabilities ?? [];
const environmentDrivers = plugin.manifestJson.environmentDrivers ?? [];
const environmentDriverNames = environmentDrivers
.map((driver) => driver.displayName?.trim() || driver.driverKey)
.filter((name, index, values) => values.indexOf(name) === index);
const driverLabel = environmentDriverNames.join(", ");
return (
<div className="space-y-6 max-w-5xl">
@@ -235,6 +240,19 @@ export function PluginSettings() {
pluginStatus={plugin.status}
supportsConfigTest={(plugin as unknown as { supportsConfigTest?: boolean }).supportsConfigTest === true}
/>
) : environmentDrivers.length > 0 ? (
<div className="rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-sm">
<p className="font-medium text-foreground">Configure this plugin from Company Environments.</p>
<p className="mt-1 text-muted-foreground">
{driverLabel || "This plugin"} registers environment runtime settings there so credentials stay
company-scoped instead of instance-global.
</p>
<div className="mt-3">
<Link to="/company/settings/environments">
<Button variant="outline" size="sm">Open Company Environments</Button>
</Link>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
This plugin does not require any settings.