From 518639fd5f3ca0f31f87ca8c29fdcb8e0199d0cc Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 29 Apr 2026 16:42:46 -0700 Subject: [PATCH] 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. --- .../plugins/sandbox-providers/e2b/README.md | 8 ++ .../sandbox-providers/e2b/src/manifest.ts | 2 +- .../src/__tests__/environment-runtime.test.ts | 7 +- server/src/services/environment-runtime.ts | 6 +- .../src/services/plugin-environment-driver.ts | 28 +++- ui/src/pages/PluginSettings.test.tsx | 125 ++++++++++++++++++ ui/src/pages/PluginSettings.tsx | 18 +++ 7 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 ui/src/pages/PluginSettings.test.tsx diff --git a/packages/plugins/sandbox-providers/e2b/README.md b/packages/plugins/sandbox-providers/e2b/README.md index a4c86eb3aa9..63e32391b00 100644 --- a/packages/plugins/sandbox-providers/e2b/README.md +++ b/packages/plugins/sandbox-providers/e2b/README.md @@ -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 diff --git a/packages/plugins/sandbox-providers/e2b/src/manifest.ts b/packages/plugins/sandbox-providers/e2b/src/manifest.ts index 04e7783a084..0a8af014f1a 100644 --- a/packages/plugins/sandbox-providers/e2b/src/manifest.ts +++ b/packages/plugins/sandbox-providers/e2b/src/manifest.ts @@ -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", diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index aa5e1afd1fb..dd9595b9eb2 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -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, diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 37cdb765d0a..ec3c9496551 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -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."); diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index 2b267eb5a26..d43c79b7d39 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -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; +}): 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; } diff --git a/ui/src/pages/PluginSettings.test.tsx b/ui/src/pages/PluginSettings.test.tsx new file mode 100644 index 00000000000..3aa99028ad3 --- /dev/null +++ b/ui/src/pages/PluginSettings.test.tsx @@ -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 }) => {children}, + 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( + + + , + ); + }); + 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(); + }); + }); +}); diff --git a/ui/src/pages/PluginSettings.tsx b/ui/src/pages/PluginSettings.tsx index 0d36b3d88b2..955bb629715 100644 --- a/ui/src/pages/PluginSettings.tsx +++ b/ui/src/pages/PluginSettings.tsx @@ -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 (
@@ -235,6 +240,19 @@ export function PluginSettings() { pluginStatus={plugin.status} supportsConfigTest={(plugin as unknown as { supportsConfigTest?: boolean }).supportsConfigTest === true} /> + ) : environmentDrivers.length > 0 ? ( +
+

Configure this plugin from Company Environments.

+

+ {driverLabel || "This plugin"} registers environment runtime settings there so credentials stay + company-scoped instead of instance-global. +

+
+ + + +
+
) : (

This plugin does not require any settings.