mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
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:
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user