diff --git a/packages/plugins/sandbox-providers/e2b/README.md b/packages/plugins/sandbox-providers/e2b/README.md index a4c86eb3..63e32391 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 04e7783a..0a8af014 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 aa5e1afd..dd9595b9 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 37cdb765..ec3c9496 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 2b267eb5..d43c79b7 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 00000000..3aa99028 --- /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 0d36b3d8..955bb629 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.