Merge upstream/master (53 commits) into local
Build: Production / build (push) Failing after 13m4s

Resolved conflicts:
- ui CompanySettingsSidebar.tsx: keep both Secrets (local) and Cloud upstream (master) nav items
- ui CompanySettingsNav.tsx + test: take master's cloud-upstream/members (drops deprecated `access` tab now consolidated into `members`)
- server plugin-worker-manager.ts: take master's 15min RPC timeout cap
- pnpm-lock.yaml: regenerated via `pnpm install` against merged package.json files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 08:01:31 -04:00
536 changed files with 60296 additions and 2542 deletions
@@ -423,6 +423,17 @@ export async function handleBridgeRequest(request: Request, env: BridgeEnv): Pro
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
// Heartbeat keeps the SSE response alive during silent stretches
// (e.g. npm install downloading silently). SSE comment lines (`:`)
// are ignored by the client parser but keep the underlying HTTP
// connection from idling out at the Cloudflare edge.
const heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
} catch {
// Controller may already be closed; ignore.
}
}, 15_000);
try {
const result = await executeInSandbox({
sandbox,
@@ -444,6 +455,7 @@ export async function handleBridgeRequest(request: Request, env: BridgeEnv): Pro
error: error instanceof Error ? error.message : String(error),
})));
} finally {
clearInterval(heartbeat);
controller.close();
}
},
@@ -7,7 +7,7 @@
{
"class_name": "Sandbox",
"image": "./Dockerfile",
"instance_type": "lite",
"instance_type": "standard-2",
"max_instances": 10
}
],
@@ -1,9 +1,9 @@
import type { CloudflareDriverConfig } from "./types.js";
const DEFAULT_REQUESTED_CWD = "/workspace/paperclip";
const DEFAULT_SLEEP_AFTER = "10m";
const DEFAULT_SLEEP_AFTER = "1h";
const DEFAULT_TIMEOUT_MS = 300_000;
const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 30_000;
const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 300_000;
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
function readTrimmedString(value: unknown): string | null {
@@ -49,8 +49,9 @@ const manifest: PaperclipPluginManifestV1 = {
},
sleepAfter: {
type: "string",
default: "10m",
description: "Idle timeout passed to getSandbox(). Ignored when keepAlive is true.",
default: "1h",
description:
"Idle timeout passed to getSandbox() on lease creation. Defaults to 1 hour so a fresh sandbox survives normal Claude/Codex heartbeats. Ignored when keepAlive is true.",
},
normalizeId: {
type: "boolean",
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import plugin from "./plugin.js";
const fetchMock = vi.fn();
let plugin: typeof import("./plugin.js").default;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@@ -23,9 +23,11 @@ function requestBodyAt(index = 0): Record<string, unknown> {
}
describe("Cloudflare sandbox provider plugin", () => {
beforeEach(() => {
beforeEach(async () => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
vi.resetModules();
plugin = (await import("./plugin.js")).default;
});
it("declares the Cloudflare environment lifecycle handlers", async () => {
@@ -60,7 +62,7 @@ describe("Cloudflare sandbox provider plugin", () => {
bridgeAuthToken: "secret-ref://bridge-token",
reuseLease: true,
keepAlive: true,
sleepAfter: "10m",
sleepAfter: "1h",
normalizeId: false,
requestedCwd: "/workspace/custom",
sessionStrategy: "default",
@@ -143,6 +145,29 @@ describe("Cloudflare sandbox provider plugin", () => {
});
});
it("defaults the sleepAfter passed to the bridge to 1h so long runs don't idle out", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
providerLeaseId: "pc-run-1-abcd1234",
metadata: { provider: "cloudflare", remoteCwd: "/workspace/paperclip", resumedLease: false },
}),
);
await plugin.definition.onEnvironmentAcquireLease?.({
driverKey: "cloudflare",
companyId: "company-1",
environmentId: "env-1",
runId: "run-1",
requestedCwd: "/workspace/paperclip",
config: {
bridgeBaseUrl: "https://bridge.example.workers.dev",
bridgeAuthToken: "resolved-token",
},
});
expect(requestBodyAt()).toMatchObject({ sleepAfter: "1h" });
});
it("returns expired lease semantics when resume reports lost state", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse(
@@ -210,6 +235,12 @@ describe("Cloudflare sandbox provider plugin", () => {
});
it("routes bridge-channel execute calls through a dedicated session", async () => {
// pluginLogger must be set for the streaming branch to be reachable, so
// we can assert that bridge-channel calls take the non-streaming path
// even when adapter sessions would otherwise stream.
await plugin.definition.setup?.({
logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined },
} as never);
fetchMock.mockResolvedValueOnce(
jsonResponse({
exitCode: 0,
@@ -248,6 +279,49 @@ describe("Cloudflare sandbox provider plugin", () => {
},
});
expect(requestBodyAt().env).not.toHaveProperty("PAPERCLIP_SANDBOX_EXEC_CHANNEL");
// Bridge-channel commands must use the non-streaming exec path. The
// @cloudflare/sandbox SDK's streaming mode can drop the final stdout
// chunk when a short shell exits the same tick it writes — bridge ops
// carry machine-consumed stdout (readiness JSON, base64 file payloads,
// queue response bodies) where that data loss surfaces as opaque
// "invalid readiness JSON" / "Invalid bridge request payload" errors.
expect(requestBodyAt().streamOutput).toBe(false);
});
it("uses streaming exec for non-bridge adapter commands so live logs flow", async () => {
// Streaming is gated on `pluginLogger` being set, which normally happens
// in `setup()`. Wire a minimal logger so the streaming branch is reachable.
await plugin.definition.setup?.({
logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined },
} as never);
fetchMock.mockResolvedValueOnce(
new Response(
"event: stdout\ndata: {\"data\":\"hello\\n\"}\n\nevent: complete\ndata: {\"exitCode\":0,\"signal\":null,\"timedOut\":false,\"stdout\":\"hello\\n\",\"stderr\":\"\"}\n\n",
{
status: 200,
headers: { "Content-Type": "text/event-stream" },
},
),
);
await plugin.definition.onEnvironmentExecute?.({
driverKey: "cloudflare",
companyId: "company-1",
environmentId: "env-1",
lease: { providerLeaseId: "pc-run-1-abcd1234", metadata: {} },
command: "echo",
args: ["hello"],
cwd: "/workspace/paperclip",
env: { KEEP_ME: "visible" },
config: {
bridgeBaseUrl: "https://bridge.example.workers.dev",
bridgeAuthToken: "resolved-token",
sessionStrategy: "named",
sessionId: "paperclip",
},
});
expect(requestBodyAt().streamOutput).toBe(true);
});
it("maps lost-lease execute errors into a deterministic command failure", async () => {
@@ -317,7 +317,13 @@ const plugin = definePlugin({
const { config, client } = bridgeClientFor(params.config);
const session = resolveExecuteSession(config, params.env);
try {
const streamingOptions = pluginLogger
// Bridge-channel commands carry machine-consumed stdout (JSON, base64,
// file contents). The @cloudflare/sandbox SDK's streaming mode can drop
// the final stdout chunk when the inner shell exits the same tick as it
// writes (e.g. `cat ready.json && exit 0`), so we never stream for
// bridge control traffic — only adapter sessions get live log forwarding.
const isBridgeChannel = params.env?.[SANDBOX_EXEC_CHANNEL_ENV] === SANDBOX_EXEC_CHANNEL_BRIDGE;
const streamingOptions = pluginLogger && !isBridgeChannel
? {
onOutput: async (stream: "stdout" | "stderr", chunk: string) => {
logCloudflareExecChunk(pluginLogger, stream, chunk);
@@ -39,8 +39,9 @@ const manifest: PaperclipPluginManifestV1 = {
},
timeoutMs: {
type: "number",
description: "Sandbox timeout in milliseconds.",
default: 300000,
description:
"Sandbox lifetime in milliseconds, refreshed on each command. Defaults to 1 hour. Raise this if your runs commonly idle longer than the default between commands.",
default: 3600000,
},
reuseLease: {
type: "boolean",
@@ -379,6 +379,59 @@ describe("E2B sandbox provider plugin", () => {
});
});
it("refreshes the sandbox lifetime on every execute so long runs don't die mid-command", async () => {
const sandbox = createMockSandbox();
mockConnect.mockResolvedValue(sandbox);
await plugin.definition.onEnvironmentExecute?.({
driverKey: "e2b",
companyId: "company-1",
environmentId: "env-1",
config: {
template: "base",
apiKey: "resolved-key",
timeoutMs: 1_800_000,
reuseLease: false,
},
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "printf",
args: ["hello"],
cwd: "/workspace",
env: {},
timeoutMs: 1000,
});
expect(sandbox.setTimeout).toHaveBeenCalledWith(1_800_000);
});
it("still runs the command when the setTimeout refresh fails transiently", async () => {
const sandbox = createMockSandbox();
sandbox.setTimeout.mockRejectedValueOnce(new Error("transient e2b api error"));
mockConnect.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "e2b",
companyId: "company-1",
environmentId: "env-1",
config: {
template: "base",
apiKey: "resolved-key",
timeoutMs: 1_800_000,
reuseLease: false,
},
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "printf",
args: ["hello"],
cwd: "/workspace",
env: {},
timeoutMs: 1000,
});
expect(sandbox.setTimeout).toHaveBeenCalledWith(1_800_000);
expect(sandbox.commands.run).toHaveBeenCalled();
expect(result?.exitCode).toBe(0);
});
it("cleans up staged stdin even when writing it fails", async () => {
const sandbox = createMockSandbox();
const failure = new Error("write failed");
@@ -34,11 +34,11 @@ function parseDriverConfig(raw: Record<string, unknown>): E2bDriverConfig {
const template = typeof raw.template === "string" && raw.template.trim().length > 0
? raw.template.trim()
: "base";
const timeoutMs = Number(raw.timeoutMs ?? 300_000);
const timeoutMs = Number(raw.timeoutMs ?? 3_600_000);
return {
template,
apiKey: typeof raw.apiKey === "string" && raw.apiKey.trim().length > 0 ? raw.apiKey.trim() : null,
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 300_000,
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 3_600_000,
reuseLease: raw.reuseLease === true,
};
}
@@ -391,6 +391,18 @@ const plugin = definePlugin({
const config = parseDriverConfig(params.config);
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
// Refresh the sandbox death clock on every command. E2B's `timeoutMs` is
// the absolute sandbox lifetime from create/connect; without this, a run
// longer than `config.timeoutMs` will have its sandbox killed mid-command
// and the next call throws "Sandbox is probably not running anymore".
// The refresh is best-effort: the sandbox is already healthy at this
// point, so a transient API error on setTimeout should not block the
// command from running. Worst case the existing lifetime stands.
try {
await sandbox.setTimeout(config.timeoutMs);
} catch {
// ignore — keep going with the existing sandbox lifetime
}
const baseCommand = buildLoginShellScript({
command: params.command,
args: params.args ?? [],
@@ -0,0 +1,77 @@
# `@paperclipai/plugin-modal`
First-party Modal sandbox provider plugin for Paperclip.
Like the other sandbox-provider packages in this repo, it lives inside the Paperclip monorepo but is intentionally excluded from the root `pnpm` workspace and shaped to publish and install like a standalone npm package. That lets operators install it from the Plugins page by package name without introducing root lockfile churn for Modal's SDK dependencies.
## Install
From a Paperclip instance, install:
```text
@paperclipai/plugin-modal
```
The host plugin installer runs `npm install` into the managed plugin directory, so the `modal` SDK dependency is pulled in during installation.
## Runtime support note
Modal's official JS SDK README pins support to **Node 22 or later**. Paperclip's repo baseline is currently `node >= 20`; empirically `modal@0.7.4` imports and operates against the Modal API under Node 20, so the plugin runs there today, but the vendor support contract is Node 22+. The plugin logs a startup warning when it detects Node `< 22`. Operators who can pin their Paperclip runtime to Node 22+ should do so; treat Node-20 usage as best-effort until the host bumps its baseline.
The empirical Node 20 compatibility check is recorded in [PAPA-352](/PAPA/issues/PAPA-352).
## Configuration
Configure Modal from `Company Settings -> Environments`, not from the plugin's instance settings page.
| Field | Required | Description |
| --- | --- | --- |
| `appName` | yes | Modal App name. The plugin calls `modal.apps.fromName(appName, { createIfMissing: true })`, so the App is created on first acquire if it does not already exist. |
| `image` | yes | Container image passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:20`. |
| `tokenId` / `tokenSecret` | yes | Modal auth tokens. Both must be provided together. Paperclip stores pasted values as company secrets. The plugin worker runs in a child process that does not inherit host env vars, so `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` set on the Paperclip server are **not** read by the plugin — provide the tokens in this form. |
| `environment` | no | Optional Modal environment name. Falls back to the SDK profile default. |
| `workdir` | no | Remote working directory inside the sandbox. Defaults to `/workspace/paperclip`. |
| `sandboxTimeoutMs` | no | Maximum sandbox lifetime in milliseconds. Must be a positive multiple of `1000` between `1000` and `86_400_000` (24 hours). Defaults to `3_600_000` (1 hour). |
| `idleTimeoutMs` | no | Optional idle timeout in milliseconds. Modal terminates the sandbox if no exec is active for this duration. Must be a positive multiple of `1000`. |
| `execTimeoutMs` | no | Default per-exec timeout in milliseconds when the caller does not pass one. Must be a positive multiple of `1000`. Defaults to `300_000` (5 minutes). |
| `blockNetwork` | no | Block all egress network access. |
| `cidrAllowlist` | no | List of CIDRs the sandbox may reach. Cannot be combined with `blockNetwork`. |
| `reuseLease` | no | When `true`, the sandbox is detached (not terminated) on release and reattached by id later. Defaults to `false`. |
### Reuse semantics
Modal does **not** expose a separate pause/resume primitive for sandboxes — there is no equivalent to e2b's `pause()`. The plugin implements `reuseLease` as follows:
- **`reuseLease: false` (default)**: On release the sandbox is `terminate()`d. Subsequent runs create a new sandbox.
- **`reuseLease: true`**: On release the plugin calls `sandbox.detach()`. The sandbox keeps running on Modal until its configured `sandboxTimeoutMs` or `idleTimeoutMs` elapses. The next acquire/resume reconnects via `modal.sandboxes.fromId(providerLeaseId)`. If the sandbox has expired, `fromId` raises `NotFoundError` and the plugin reports the lease as expired so Paperclip reacquires.
Because there is no real pause, **`reuseLease: true` keeps billing running** until the sandbox or idle timeout cuts it off. Tune `idleTimeoutMs` to a value that matches your reuse window.
## Local development
```bash
cd packages/plugins/sandbox-providers/modal
pnpm install --ignore-workspace --no-lockfile
pnpm build
pnpm test
pnpm typecheck
```
These commands assume the repo root has already been installed once so the local `@paperclipai/plugin-sdk` workspace package is available to the compiler during development.
## Operator verification
1. Provision Modal credentials in your Modal account (`modal token new`) or use a service account.
2. Install the plugin from the Paperclip Plugins page.
3. In `Company Settings -> Environments`, add a new Modal sandbox environment with at least `appName`, `image`, `tokenId`, and `tokenSecret`.
4. Run the environment **Probe** action. A success result confirms auth, app creation, image pull, and `exec` round-trip.
5. Run at least one Paperclip task with a remote-managed adapter (for example `claude_local`) bound to that environment. The adapter should provision the sandbox, run commands in it, and clean it up.
Full end-to-end manual QA is tracked separately in [PAPA-354](/PAPA/issues/PAPA-354).
## Package layout
- `src/manifest.ts` declares the sandbox-provider driver metadata
- `src/plugin.ts` implements the environment lifecycle hooks
- `src/worker.ts` boots the plugin under the host worker runtime
- `paperclipPlugin.manifest` and `paperclipPlugin.worker` point the host at the built plugin entrypoints in `dist/`
@@ -0,0 +1,61 @@
{
"name": "@paperclipai/plugin-modal",
"version": "0.1.0",
"description": "Modal sandbox provider plugin for Paperclip environments",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/plugins/sandbox-providers/modal"
},
"type": "module",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js"
},
"keywords": [
"paperclip",
"plugin",
"sandbox",
"modal"
],
"scripts": {
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "rm -rf dist && tsc",
"clean": "rm -rf dist",
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
"test": "vitest run --config vitest.config.ts",
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs",
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
"dependencies": {
"modal": "^0.7.4"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as plugin } from "./plugin.js";
@@ -0,0 +1,101 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.modal-sandbox-provider";
const PLUGIN_VERSION = "0.1.0";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: PLUGIN_VERSION,
displayName: "Modal Sandbox Provider",
description:
"First-party sandbox provider plugin that provisions Modal sandboxes as Paperclip execution environments.",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: {
worker: "./dist/worker.js",
},
environmentDrivers: [
{
driverKey: "modal",
kind: "sandbox_provider",
displayName: "Modal Sandbox",
description:
"Provisions Modal sandboxes with configurable image, app, auth, timeouts, and network controls.",
configSchema: {
type: "object",
required: ["appName", "image"],
properties: {
appName: {
type: "string",
description:
"Modal App name used as the parent for sandboxes. The plugin calls `modal.apps.fromName(appName, { createIfMissing: true })`, so the App is created on first acquire if it does not already exist.",
},
image: {
type: "string",
description:
"Container image reference passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:20`.",
},
tokenId: {
type: "string",
format: "secret-ref",
description:
"Modal token ID. Paste a token or an existing Paperclip secret reference; saved environments store pasted values as company secrets. Required.",
},
tokenSecret: {
type: "string",
format: "secret-ref",
description: "Modal token secret paired with tokenId. Required.",
},
environment: {
type: "string",
description:
"Optional Modal environment name. Falls back to the SDK profile default.",
},
workdir: {
type: "string",
description: "Remote working directory inside the sandbox.",
default: "/workspace/paperclip",
},
sandboxTimeoutMs: {
type: "number",
description:
"Maximum sandbox lifetime in milliseconds. Must be a positive multiple of 1000 between 1000 and 86400000 (24 hours).",
default: 3_600_000,
},
idleTimeoutMs: {
type: "number",
description:
"Optional idle timeout in milliseconds. When set, Modal terminates the sandbox if no exec is active for this duration. Must be a positive multiple of 1000.",
},
execTimeoutMs: {
type: "number",
description:
"Default per-exec timeout in milliseconds when the caller does not provide one. Must be a positive multiple of 1000.",
default: 300_000,
},
blockNetwork: {
type: "boolean",
description: "Whether to block all egress network access from the sandbox.",
default: false,
},
cidrAllowlist: {
type: "array",
items: { type: "string" },
description:
"Optional list of CIDRs the sandbox is allowed to reach. Cannot be combined with blockNetwork.",
},
reuseLease: {
type: "boolean",
description:
"When true, the sandbox is detached (not terminated) on release and resumed by id later. Reuse relies on Modal's sandbox lifetime and idle timeout because Modal has no separate pause primitive.",
default: false,
},
},
},
},
],
};
export default manifest;
@@ -0,0 +1,703 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { MockNotFoundError, MockTimeoutError, MockSandboxTimeoutError } = vi.hoisted(() => {
class MockNotFoundError extends Error {}
class MockTimeoutError extends Error {}
class MockSandboxTimeoutError extends Error {}
return { MockNotFoundError, MockTimeoutError, MockSandboxTimeoutError };
});
const mockAppFromName = vi.hoisted(() => vi.fn());
const mockImageFromRegistry = vi.hoisted(() => vi.fn(() => ({ kind: "image" })));
const mockSandboxesCreate = vi.hoisted(() => vi.fn());
const mockSandboxesFromId = vi.hoisted(() => vi.fn());
const mockClientClose = vi.hoisted(() => vi.fn());
vi.mock("modal", () => ({
ModalClient: class MockModalClient {
apps = { fromName: mockAppFromName };
images = { fromRegistry: mockImageFromRegistry };
sandboxes = { create: mockSandboxesCreate, fromId: mockSandboxesFromId };
close = mockClientClose;
constructor(_params?: unknown) {}
},
NotFoundError: MockNotFoundError,
TimeoutError: MockTimeoutError,
SandboxTimeoutError: MockSandboxTimeoutError,
}));
import plugin from "./plugin.js";
interface FakeSandboxOverrides {
id?: string;
execImpl?: (argv: string[], params?: unknown) => Promise<FakeProcess>;
}
interface FakeProcess {
stdout: { readText: () => Promise<string> };
stderr: { readText: () => Promise<string> };
wait: () => Promise<number>;
}
function makeFakeProcess(input: {
exitCode?: number;
stdout?: string;
stderr?: string;
throwOnWait?: unknown;
}): FakeProcess {
return {
stdout: { readText: vi.fn().mockResolvedValue(input.stdout ?? "") },
stderr: { readText: vi.fn().mockResolvedValue(input.stderr ?? "") },
wait: vi.fn().mockImplementation(async () => {
if (input.throwOnWait) throw input.throwOnWait;
return input.exitCode ?? 0;
}),
};
}
function createFakeSandbox(overrides: FakeSandboxOverrides = {}) {
const execCalls: Array<{ argv: string[]; params?: unknown }> = [];
const defaultExec = async (_argv: string[], _params?: unknown): Promise<FakeProcess> =>
makeFakeProcess({ exitCode: 0, stdout: "paperclip-probe" });
const exec = vi.fn().mockImplementation(async (argv: string[], params?: unknown) => {
execCalls.push({ argv, params });
return overrides.execImpl ? overrides.execImpl(argv, params) : defaultExec(argv, params);
});
const openedFiles: Array<{ path: string; mode: string; written: Uint8Array | null }> = [];
const sandbox = {
sandboxId: overrides.id ?? "sb-123",
exec,
execCalls,
openedFiles,
setTags: vi.fn().mockResolvedValue(undefined),
terminate: vi.fn().mockResolvedValue(undefined),
detach: vi.fn(),
poll: vi.fn().mockResolvedValue(null),
open: vi.fn().mockImplementation(async (path: string, mode: string) => {
const entry: { path: string; mode: string; written: Uint8Array | null } = {
path,
mode,
written: null,
};
openedFiles.push(entry);
return {
write: vi.fn().mockImplementation(async (data: Uint8Array) => {
entry.written = data;
}),
flush: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
}),
};
return sandbox;
}
type FakeSandbox = ReturnType<typeof createFakeSandbox>;
const baseAcquireParams = {
driverKey: "modal",
companyId: "company-1",
environmentId: "env-1",
runId: "run-1",
};
const baseConfig = {
appName: "paperclip-app",
image: "node:20",
sandboxTimeoutMs: 3_600_000,
execTimeoutMs: 300_000,
reuseLease: false,
};
const baseConfigWithTokens = {
...baseConfig,
tokenId: "config-id",
tokenSecret: "config-secret",
};
beforeEach(() => {
mockAppFromName.mockReset();
mockImageFromRegistry.mockReset();
mockImageFromRegistry.mockReturnValue({ kind: "image" });
mockSandboxesCreate.mockReset();
mockSandboxesFromId.mockReset();
mockClientClose.mockReset();
vi.restoreAllMocks();
delete process.env.MODAL_TOKEN_ID;
delete process.env.MODAL_TOKEN_SECRET;
});
describe("Modal sandbox provider plugin", () => {
it("declares environment lifecycle handlers", async () => {
expect(await plugin.definition.onHealth?.()).toEqual({
status: "ok",
message: "Modal sandbox provider plugin healthy",
});
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function");
expect(plugin.definition.onEnvironmentReleaseLease).toBeTypeOf("function");
expect(plugin.definition.onEnvironmentResumeLease).toBeTypeOf("function");
});
it("normalizes config when both tokens are provided", async () => {
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "modal",
config: {
appName: " app-1 ",
image: " node:20 ",
tokenId: " token-id ",
tokenSecret: " token-secret ",
environment: " main ",
workdir: " /srv/work ",
sandboxTimeoutMs: "1800000",
idleTimeoutMs: "60000",
execTimeoutMs: "120000",
reuseLease: true,
blockNetwork: false,
cidrAllowlist: ["10.0.0.0/8"],
},
});
expect(result).toEqual({
ok: true,
normalizedConfig: {
appName: "app-1",
image: "node:20",
tokenId: "token-id",
tokenSecret: "token-secret",
environment: "main",
workdir: "/srv/work",
sandboxTimeoutMs: 1_800_000,
idleTimeoutMs: 60_000,
execTimeoutMs: 120_000,
blockNetwork: false,
cidrAllowlist: ["10.0.0.0/8"],
reuseLease: true,
},
});
});
it("ignores host MODAL_TOKEN_* env vars (plugin worker does not inherit them)", async () => {
process.env.MODAL_TOKEN_ID = "host-id";
process.env.MODAL_TOKEN_SECRET = "host-secret";
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "modal",
config: { ...baseConfig },
});
expect(result).toEqual({
ok: false,
errors: ["Modal sandbox environments require tokenId and tokenSecret."],
});
});
it("rejects invalid config", async () => {
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "modal",
config: {
appName: "",
image: "",
sandboxTimeoutMs: 1500,
idleTimeoutMs: 1500,
execTimeoutMs: 0,
blockNetwork: true,
cidrAllowlist: ["1.2.3.4/32"],
tokenId: "only-id",
},
});
expect(result).toEqual({
ok: false,
errors: [
"Modal sandbox environments require an appName.",
"Modal sandbox environments require an image reference.",
"sandboxTimeoutMs must be a positive multiple of 1000 between 1000 and 86400000.",
"idleTimeoutMs must be a positive multiple of 1000 when provided.",
"execTimeoutMs must be a positive multiple of 1000.",
"cidrAllowlist cannot be combined with blockNetwork.",
"tokenId and tokenSecret must both be provided when either is set.",
],
});
});
it("requires both tokens in config", async () => {
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "modal",
config: { ...baseConfig },
});
expect(result).toEqual({
ok: false,
errors: ["Modal sandbox environments require tokenId and tokenSecret."],
});
});
it("probes by creating, executing, and terminating a sandbox", async () => {
const sandbox = createFakeSandbox();
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
mockSandboxesCreate.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentProbe?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: { ...baseConfig, workdir: "/srv/work" },
});
expect(mockAppFromName).toHaveBeenCalledWith("paperclip-app", {
createIfMissing: true,
environment: undefined,
});
expect(mockImageFromRegistry).toHaveBeenCalledWith("node:20");
expect(sandbox.setTags).toHaveBeenCalledWith(expect.objectContaining({
"paperclip-provider": "modal",
"paperclip-company-id": "c-1",
}));
// First exec is the mkdir for the workspace, second is the probe command.
expect(sandbox.execCalls[0]?.argv).toEqual([
"sh",
"-lc",
"mkdir -p '/srv/work'",
]);
expect(sandbox.execCalls[1]?.argv).toEqual([
"sh",
"-lc",
"printf paperclip-probe",
]);
expect(sandbox.terminate).toHaveBeenCalled();
expect(mockClientClose).toHaveBeenCalled();
expect(result).toMatchObject({
ok: true,
metadata: {
provider: "modal",
sandboxId: "sb-123",
remoteCwd: "/srv/work",
reuseLease: false,
},
});
});
it("returns a failure probe result when the probe command exits non-zero", async () => {
const sandbox = createFakeSandbox({
execImpl: async (argv: string[]) => {
if (argv[2] === "printf paperclip-probe") {
return makeFakeProcess({ exitCode: 7, stdout: "boom" });
}
return makeFakeProcess({ exitCode: 0 });
},
});
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
mockSandboxesCreate.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentProbe?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
});
expect(result?.ok).toBe(false);
expect(sandbox.terminate).toHaveBeenCalled();
});
it("closes the Modal client when probe fails before sandbox creation", async () => {
mockAppFromName.mockRejectedValue(new Error("app lookup failed"));
const result = await plugin.definition.onEnvironmentProbe?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
});
expect(result).toMatchObject({
ok: false,
summary: "Modal sandbox probe failed.",
metadata: expect.objectContaining({
error: "app lookup failed",
}),
});
expect(mockClientClose).toHaveBeenCalledTimes(1);
});
it("acquires a lease, applies tags, and ensures the workspace directory", async () => {
const sandbox = createFakeSandbox({ id: "sb-acquire" });
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
mockSandboxesCreate.mockResolvedValue(sandbox);
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
...baseAcquireParams,
config: { ...baseConfig, reuseLease: true, workdir: "/srv/work" },
});
expect(lease).toEqual({
providerLeaseId: "sb-acquire",
metadata: expect.objectContaining({
provider: "modal",
sandboxId: "sb-acquire",
remoteCwd: "/srv/work",
reuseLease: true,
resumedLease: false,
}),
});
expect(sandbox.setTags).toHaveBeenCalledWith(expect.objectContaining({
"paperclip-run-id": "run-1",
"paperclip-reuse-lease": "true",
}));
expect(sandbox.execCalls[0]?.argv).toEqual(["sh", "-lc", "mkdir -p '/srv/work'"]);
});
it("terminates the sandbox if acquire workspace setup throws", async () => {
const sandbox = createFakeSandbox({
execImpl: async (argv: string[]) => {
if (argv[2]?.startsWith("mkdir -p")) {
return makeFakeProcess({ throwOnWait: new Error("mkdir failed") });
}
return makeFakeProcess({ exitCode: 0 });
},
});
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
mockSandboxesCreate.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentAcquireLease?.({
...baseAcquireParams,
config: baseConfig,
}),
).rejects.toThrow("mkdir failed");
expect(sandbox.terminate).toHaveBeenCalledTimes(1);
});
it("fails acquire when workspace creation exits non-zero", async () => {
const sandbox = createFakeSandbox({
execImpl: async (argv: string[]) => {
if (argv[2]?.startsWith("mkdir -p")) {
return makeFakeProcess({ exitCode: 17 });
}
return makeFakeProcess({ exitCode: 0 });
},
});
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
mockSandboxesCreate.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentAcquireLease?.({
...baseAcquireParams,
config: baseConfig,
}),
).rejects.toThrow(
"Failed to create remote workspace directory '/workspace/paperclip': mkdir exited with code 17",
);
expect(sandbox.terminate).toHaveBeenCalledTimes(1);
});
it("closes the Modal client when acquire fails before sandbox creation", async () => {
mockAppFromName.mockRejectedValue(new Error("app lookup failed"));
await expect(
plugin.definition.onEnvironmentAcquireLease?.({
...baseAcquireParams,
config: baseConfig,
}),
).rejects.toThrow("app lookup failed");
expect(mockClientClose).toHaveBeenCalledTimes(1);
});
it("treats missing leases as expired on resume", async () => {
mockSandboxesFromId.mockRejectedValue(new MockNotFoundError("gone"));
const lease = await plugin.definition.onEnvironmentResumeLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-missing",
config: { ...baseConfig, reuseLease: true },
});
expect(lease).toEqual({ providerLeaseId: null, metadata: { expired: true } });
});
it("resumes a reusable lease by reconnecting via fromId", async () => {
const sandbox = createFakeSandbox({ id: "sb-resume" });
mockSandboxesFromId.mockResolvedValue(sandbox);
const lease = await plugin.definition.onEnvironmentResumeLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-resume",
config: { ...baseConfig, reuseLease: true },
});
expect(lease).toEqual({
providerLeaseId: "sb-resume",
metadata: expect.objectContaining({
provider: "modal",
sandboxId: "sb-resume",
resumedLease: true,
reuseLease: true,
}),
});
});
it("detaches the sandbox if resumed workspace setup fails", async () => {
const sandbox = createFakeSandbox({
id: "sb-resume",
execImpl: async (argv: string[]) => {
if (argv[2]?.startsWith("mkdir -p")) {
return makeFakeProcess({ throwOnWait: new Error("mkdir failed") });
}
return makeFakeProcess({ exitCode: 0 });
},
});
mockSandboxesFromId.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentResumeLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-resume",
config: { ...baseConfig, reuseLease: true },
}),
).rejects.toThrow("mkdir failed");
expect(sandbox.detach).toHaveBeenCalledTimes(1);
});
it("detaches reusable leases and terminates ephemeral leases on release", async () => {
const reusable = createFakeSandbox({ id: "sb-reuse" });
const ephemeral = createFakeSandbox({ id: "sb-ephem" });
mockSandboxesFromId.mockResolvedValueOnce(reusable).mockResolvedValueOnce(ephemeral);
await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-reuse",
config: { ...baseConfig, reuseLease: true },
});
await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-ephem",
config: { ...baseConfig, reuseLease: false },
});
expect(reusable.detach).toHaveBeenCalled();
expect(reusable.terminate).not.toHaveBeenCalled();
expect(ephemeral.terminate).toHaveBeenCalled();
expect(ephemeral.detach).not.toHaveBeenCalled();
});
it("destroys leases by terminating, ignoring missing sandboxes", async () => {
const sandbox = createFakeSandbox({ id: "sb-destroy" });
mockSandboxesFromId.mockResolvedValueOnce(sandbox);
await plugin.definition.onEnvironmentDestroyLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-destroy",
config: baseConfig,
});
expect(sandbox.terminate).toHaveBeenCalled();
mockSandboxesFromId.mockRejectedValueOnce(new MockNotFoundError("missing"));
await expect(
plugin.definition.onEnvironmentDestroyLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-missing",
config: baseConfig,
}),
).resolves.toBeUndefined();
});
it("realizes the workspace using the lease metadata cwd when available", async () => {
const sandbox = createFakeSandbox({ id: "sb-real" });
mockSandboxesFromId.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentRealizeWorkspace?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: {
providerLeaseId: "sb-real",
metadata: { remoteCwd: "/srv/from-metadata" },
},
workspace: { localPath: "/local", remotePath: "/remote" },
});
expect(sandbox.execCalls[0]?.argv).toEqual([
"sh",
"-lc",
"mkdir -p '/srv/from-metadata'",
]);
expect(result).toEqual({
cwd: "/srv/from-metadata",
metadata: { provider: "modal", remoteCwd: "/srv/from-metadata" },
});
});
it("executes commands with a login-shell wrapper that injects env after profile sourcing", async () => {
const sandbox = createFakeSandbox({
execImpl: async (argv: string[]) =>
makeFakeProcess({
exitCode: 5,
stdout: "stdout-output",
stderr: "stderr-output",
}),
});
mockSandboxesFromId.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: "sb-exec", metadata: {} },
command: "printf",
args: ["hello"],
cwd: "/srv/work",
env: { FOO: "bar" },
timeoutMs: 12_000,
});
expect(sandbox.execCalls).toHaveLength(1);
const call = sandbox.execCalls[0]!;
expect(call.argv[0]).toBe("sh");
expect(call.argv[1]).toBe("-lc");
const script = call.argv[2]!;
expect(script).toMatch(/\/etc\/profile/);
expect(script).toMatch(/cd '\/srv\/work'/);
expect(script).toMatch(/&& exec env FOO='bar' 'printf' 'hello'$/);
expect(call.params).toMatchObject({
timeoutMs: 12_000,
stdout: "pipe",
stderr: "pipe",
});
expect(result).toEqual({
exitCode: 5,
timedOut: false,
stdout: "stdout-output",
stderr: "stderr-output",
});
});
it("stages stdin in the sandbox filesystem when execution needs redirected input", async () => {
const sandbox = createFakeSandbox();
mockSandboxesFromId.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: "sb-exec", metadata: {} },
command: "cat",
args: [],
stdin: "input payload",
cwd: "/srv/work",
});
expect(sandbox.openedFiles).toHaveLength(1);
expect(sandbox.openedFiles[0]?.path).toMatch(/^\/tmp\/paperclip-stdin-/);
expect(sandbox.openedFiles[0]?.mode).toBe("w");
expect(sandbox.openedFiles[0]?.written).not.toBeNull();
expect(new TextDecoder().decode(sandbox.openedFiles[0]!.written!)).toBe("input payload");
// First exec is the user command; second is the rm cleanup.
const userCall = sandbox.execCalls[0]!;
expect(userCall.argv[2]).toMatch(/&& exec 'cat' < '\/tmp\/paperclip-stdin-/);
const cleanupCall = sandbox.execCalls[1]!;
expect(cleanupCall.argv[2]).toMatch(/^rm -f '\/tmp\/paperclip-stdin-/);
expect(result?.exitCode).toBe(0);
});
it("rejects invalid shell env keys before execution", async () => {
const sandbox = createFakeSandbox();
mockSandboxesFromId.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: "sb-exec", metadata: {} },
command: "printf",
args: ["hello"],
env: { "BAD-KEY": "v" },
}),
).rejects.toThrow("Invalid sandbox environment variable key: BAD-KEY");
expect(sandbox.execCalls).toHaveLength(0);
});
it("returns an error result when execute is called for an expired sandbox lease", async () => {
mockSandboxesFromId.mockRejectedValue(new MockNotFoundError("gone"));
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: "sb-expired", metadata: {} },
command: "printf",
args: ["hello"],
});
expect(result).toEqual({
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "Modal sandbox lease is no longer available.\n",
});
});
it("returns a timedOut result when Modal raises a TimeoutError during exec", async () => {
const sandbox = createFakeSandbox({
execImpl: async () =>
makeFakeProcess({ throwOnWait: new MockTimeoutError("exec timed out") }),
});
mockSandboxesFromId.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: "sb-exec", metadata: {} },
command: "sleep",
args: ["60"],
cwd: "/srv/work",
timeoutMs: 5_000,
});
expect(result).toEqual({
exitCode: null,
timedOut: true,
stdout: "",
stderr: "exec timed out\n",
});
});
it("returns an error result when execute is called without a provider lease id", async () => {
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: null, metadata: {} },
command: "printf",
args: ["hello"],
});
expect(result).toEqual({
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "No provider lease ID available for execution.",
});
});
});
@@ -0,0 +1,660 @@
import {
ModalClient,
NotFoundError,
SandboxTimeoutError,
TimeoutError,
type App,
type ContainerProcess,
type Sandbox,
type SandboxCreateParams,
} from "modal";
import { definePlugin } from "@paperclipai/plugin-sdk";
import type {
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentDestroyLeaseParams,
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
PluginEnvironmentLease,
PluginEnvironmentProbeParams,
PluginEnvironmentProbeResult,
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
} from "@paperclipai/plugin-sdk";
const DEFAULT_WORKDIR = "/workspace/paperclip";
const DEFAULT_SANDBOX_TIMEOUT_MS = 3_600_000;
const DEFAULT_EXEC_TIMEOUT_MS = 300_000;
const MAX_SANDBOX_TIMEOUT_MS = 86_400_000;
interface ModalDriverConfig {
appName: string;
image: string;
tokenId: string | null;
tokenSecret: string | null;
environment: string | null;
workdir: string;
sandboxTimeoutMs: number;
idleTimeoutMs: number | null;
execTimeoutMs: number;
blockNetwork: boolean;
cidrAllowlist: string[] | null;
reuseLease: boolean;
}
function parseOptionalString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function parseOptionalNumber(value: unknown): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const trimmed = value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return trimmed.length > 0 ? trimmed : null;
}
export function parseDriverConfig(raw: Record<string, unknown>): ModalDriverConfig {
const sandboxTimeoutMsRaw = parseOptionalNumber(raw.sandboxTimeoutMs);
const execTimeoutMsRaw = parseOptionalNumber(raw.execTimeoutMs);
const idleTimeoutMsRaw = parseOptionalNumber(raw.idleTimeoutMs);
return {
appName: parseOptionalString(raw.appName) ?? "",
image: parseOptionalString(raw.image) ?? "",
tokenId: parseOptionalString(raw.tokenId),
tokenSecret: parseOptionalString(raw.tokenSecret),
environment: parseOptionalString(raw.environment),
workdir: parseOptionalString(raw.workdir) ?? DEFAULT_WORKDIR,
sandboxTimeoutMs:
sandboxTimeoutMsRaw != null ? Math.trunc(sandboxTimeoutMsRaw) : DEFAULT_SANDBOX_TIMEOUT_MS,
idleTimeoutMs: idleTimeoutMsRaw != null ? Math.trunc(idleTimeoutMsRaw) : null,
execTimeoutMs:
execTimeoutMsRaw != null ? Math.trunc(execTimeoutMsRaw) : DEFAULT_EXEC_TIMEOUT_MS,
blockNetwork: raw.blockNetwork === true,
cidrAllowlist: parseStringArray(raw.cidrAllowlist),
reuseLease: raw.reuseLease === true,
};
}
function isMultipleOf1000(value: number): boolean {
return value > 0 && value % 1000 === 0;
}
function resolveAuth(config: ModalDriverConfig): { tokenId: string; tokenSecret: string } | null {
// The plugin worker runs in a child process that does not inherit host env
// vars (see PluginWorkerManager.spawnProcess), so MODAL_TOKEN_ID /
// MODAL_TOKEN_SECRET cannot be read here. Credentials must come from the
// environment config, which Paperclip stores as company secrets.
const tokenId = config.tokenId ?? "";
const tokenSecret = config.tokenSecret ?? "";
if (!tokenId && !tokenSecret) return null;
if (!tokenId || !tokenSecret) {
throw new Error("Modal sandbox environments require both tokenId and tokenSecret to be configured.");
}
return { tokenId, tokenSecret };
}
function createModalClient(config: ModalDriverConfig): ModalClient {
const auth = resolveAuth(config);
const params: ConstructorParameters<typeof ModalClient>[0] = {};
if (auth) {
params.tokenId = auth.tokenId;
params.tokenSecret = auth.tokenSecret;
}
if (config.environment) {
params.environment = config.environment;
}
return new ModalClient(params);
}
async function resolveApp(client: ModalClient, config: ModalDriverConfig): Promise<App> {
return await client.apps.fromName(config.appName, {
createIfMissing: true,
environment: config.environment ?? undefined,
});
}
function buildSandboxCreateParams(input: {
config: ModalDriverConfig;
tags: Record<string, string>;
}): SandboxCreateParams {
const params: SandboxCreateParams = {
workdir: input.config.workdir,
timeoutMs: input.config.sandboxTimeoutMs,
blockNetwork: input.config.blockNetwork,
};
if (input.config.idleTimeoutMs != null) {
params.idleTimeoutMs = input.config.idleTimeoutMs;
}
if (input.config.cidrAllowlist && input.config.cidrAllowlist.length > 0) {
params.cidrAllowlist = input.config.cidrAllowlist;
}
// Modal sandboxes accept tag metadata via setTags after creation; the create
// RPC does not take tags directly. We pass them through input so the caller
// can apply them after `create` resolves.
void input.tags;
return params;
}
function buildSandboxTags(input: {
companyId: string;
environmentId: string;
runId?: string;
reuseLease: boolean;
}): Record<string, string> {
return {
"paperclip-provider": "modal",
"paperclip-company-id": input.companyId,
"paperclip-environment-id": input.environmentId,
"paperclip-reuse-lease": input.reuseLease ? "true" : "false",
...(input.runId ? { "paperclip-run-id": input.runId } : {}),
};
}
async function createSandboxFor(
client: ModalClient,
app: App,
config: ModalDriverConfig,
tags: Record<string, string>,
): Promise<Sandbox> {
const image = client.images.fromRegistry(config.image);
const params = buildSandboxCreateParams({ config, tags });
const sandbox = await client.sandboxes.create(app, image, params);
try {
await sandbox.setTags(tags);
} catch (error) {
// setTags is best-effort metadata; surface but do not block lease creation.
console.warn(`Failed to set tags on Modal sandbox ${sandbox.sandboxId}: ${formatErrorMessage(error)}`);
}
return sandbox;
}
function leaseMetadata(input: {
config: ModalDriverConfig;
sandbox: Sandbox;
remoteCwd: string;
resumedLease: boolean;
}) {
return {
provider: "modal",
shellCommand: "sh",
sandboxId: input.sandbox.sandboxId,
appName: input.config.appName,
image: input.config.image,
sandboxTimeoutMs: input.config.sandboxTimeoutMs,
idleTimeoutMs: input.config.idleTimeoutMs,
reuseLease: input.config.reuseLease,
remoteCwd: input.remoteCwd,
resumedLease: input.resumedLease,
};
}
function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function isValidShellEnvKey(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
// Modal's `sandbox.exec` takes an argv array and bypasses the shell entirely,
// so adapter probes that rely on PATH mutations from /etc/profile or ~/.bashrc
// do not work without an explicit login shell. Mirroring the Daytona / E2B
// providers, wrap the user command in a `sh -lc` script that sources common
// login profiles plus nvm before invoking it. Env is set after profile sourcing
// so caller env wins; stdin is staged to a temp file and shell-redirected so
// fast-failing commands do not race a streaming stdin writer.
function buildLoginShellScript(input: {
command: string;
args: string[];
cwd?: string;
env?: Record<string, string>;
stdinPath?: string;
}): string {
const env = input.env ?? {};
for (const key of Object.keys(env)) {
if (!isValidShellEnvKey(key)) {
throw new Error(`Invalid sandbox environment variable key: ${key}`);
}
}
const envArgs = Object.entries(env)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(([key, value]) => `${key}=${shellQuote(value)}`);
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
const redirected = input.stdinPath
? `${commandParts} < ${shellQuote(input.stdinPath)}`
: commandParts;
const finalLine = envArgs.length > 0 ? `exec env ${envArgs.join(" ")} ${redirected}` : `exec ${redirected}`;
const lines = [
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
];
if (input.cwd) {
lines.push(`cd ${shellQuote(input.cwd)}`);
}
lines.push(finalLine);
return lines.join(" && ");
}
async function ensureRemoteWorkspace(sandbox: Sandbox, remoteCwd: string): Promise<void> {
// Use a one-shot exec to mkdir -p; Modal does not expose a direct
// filesystem mkdir helper and creating a file via `open()` does not create
// intermediate directories.
const proc = await sandbox.exec(["sh", "-lc", `mkdir -p ${shellQuote(remoteCwd)}`]);
const exitCode = await proc.wait();
if (exitCode !== 0) {
throw new Error(
`Failed to create remote workspace directory '${remoteCwd}': mkdir exited with code ${exitCode}`,
);
}
}
async function stageStdin(sandbox: Sandbox, stdin: string, remotePath: string): Promise<void> {
const file = await sandbox.open(remotePath, "w");
try {
await file.write(new TextEncoder().encode(stdin));
await file.flush();
} finally {
await file.close().catch(() => undefined);
}
}
async function deleteStdinPath(sandbox: Sandbox, remotePath: string): Promise<void> {
// Best-effort cleanup of the staged stdin file. We swallow errors because
// it is fine for the file to outlive the sandbox if it is going to be
// terminated, and a missing rm tool would otherwise mask the real result.
try {
const proc = await sandbox.exec(["sh", "-lc", `rm -f ${shellQuote(remotePath)}`]);
await proc.wait();
} catch {
// ignore
}
}
async function readProcessStreams(
proc: ContainerProcess<string>,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const [stdout, stderr, exitCode] = await Promise.all([
proc.stdout.readText(),
proc.stderr.readText(),
proc.wait(),
]);
return { stdout, stderr, exitCode };
}
function isModalNotFound(error: unknown): boolean {
return error instanceof NotFoundError;
}
async function getSandboxOrNull(
client: ModalClient,
providerLeaseId: string,
): Promise<Sandbox | null> {
try {
return await client.sandboxes.fromId(providerLeaseId);
} catch (error) {
if (isModalNotFound(error)) return null;
throw error;
}
}
function warnIfUnsupportedNode(logger: { warn: (msg: string) => void } | undefined): void {
const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
if (Number.isFinite(major) && major < 22) {
const message = `Modal sandbox provider is running on Node ${process.versions.node}; Modal officially supports Node 22+. The plugin will attempt to operate but vendor support is not guaranteed below Node 22.`;
logger?.warn(message);
}
}
function leaseRemoteCwd(metadata: Record<string, unknown> | undefined, fallback: string): string {
if (metadata && typeof metadata.remoteCwd === "string" && metadata.remoteCwd.trim().length > 0) {
return metadata.remoteCwd.trim();
}
return fallback;
}
const plugin = definePlugin({
async setup(ctx) {
warnIfUnsupportedNode(ctx.logger);
ctx.logger.info("Modal sandbox provider plugin ready");
},
async onHealth() {
return { status: "ok", message: "Modal sandbox provider plugin healthy" };
},
async onEnvironmentValidateConfig(
params: PluginEnvironmentValidateConfigParams,
): Promise<PluginEnvironmentValidationResult> {
const config = parseDriverConfig(params.config);
const errors: string[] = [];
if (!config.appName) {
errors.push("Modal sandbox environments require an appName.");
}
if (!config.image) {
errors.push("Modal sandbox environments require an image reference.");
}
if (
config.sandboxTimeoutMs < 1000 ||
config.sandboxTimeoutMs > MAX_SANDBOX_TIMEOUT_MS ||
!isMultipleOf1000(config.sandboxTimeoutMs)
) {
errors.push(
"sandboxTimeoutMs must be a positive multiple of 1000 between 1000 and 86400000.",
);
}
if (
config.idleTimeoutMs != null &&
(config.idleTimeoutMs < 1000 || !isMultipleOf1000(config.idleTimeoutMs))
) {
errors.push("idleTimeoutMs must be a positive multiple of 1000 when provided.");
}
if (config.execTimeoutMs < 1000 || !isMultipleOf1000(config.execTimeoutMs)) {
errors.push("execTimeoutMs must be a positive multiple of 1000.");
}
if (config.blockNetwork && config.cidrAllowlist && config.cidrAllowlist.length > 0) {
errors.push("cidrAllowlist cannot be combined with blockNetwork.");
}
const hasTokenId = Boolean(config.tokenId);
const hasTokenSecret = Boolean(config.tokenSecret);
if (hasTokenId !== hasTokenSecret) {
errors.push("tokenId and tokenSecret must both be provided when either is set.");
} else if (!hasTokenId) {
errors.push("Modal sandbox environments require tokenId and tokenSecret.");
}
if (errors.length > 0) {
return { ok: false, errors };
}
return { ok: true, normalizedConfig: { ...config } };
},
async onEnvironmentProbe(
params: PluginEnvironmentProbeParams,
): Promise<PluginEnvironmentProbeResult> {
const config = parseDriverConfig(params.config);
const tags = buildSandboxTags({
companyId: params.companyId,
environmentId: params.environmentId,
reuseLease: false,
});
const client = createModalClient(config);
try {
const app = await resolveApp(client, config);
const sandbox = await createSandboxFor(client, app, config, tags);
try {
await ensureRemoteWorkspace(sandbox, config.workdir);
const proc = await sandbox.exec(["sh", "-lc", "printf paperclip-probe"]);
const { stdout, exitCode } = await readProcessStreams(proc);
if (exitCode !== 0 || stdout.trim() !== "paperclip-probe") {
return {
ok: false,
summary: `Modal sandbox probe failed: exit ${exitCode}, stdout=${JSON.stringify(stdout)}`,
metadata: {
provider: "modal",
sandboxId: sandbox.sandboxId,
appName: config.appName,
image: config.image,
},
};
}
return {
ok: true,
summary: `Connected to Modal sandbox in app ${config.appName}.`,
metadata: {
provider: "modal",
sandboxId: sandbox.sandboxId,
appName: config.appName,
image: config.image,
workdir: config.workdir,
sandboxTimeoutMs: config.sandboxTimeoutMs,
idleTimeoutMs: config.idleTimeoutMs,
reuseLease: config.reuseLease,
remoteCwd: config.workdir,
},
};
} finally {
await sandbox.terminate().catch(() => undefined);
}
} catch (error) {
return {
ok: false,
summary: "Modal sandbox probe failed.",
metadata: {
provider: "modal",
appName: config.appName,
image: config.image,
reuseLease: config.reuseLease,
error: formatErrorMessage(error),
},
};
} finally {
client.close();
}
},
async onEnvironmentAcquireLease(
params: PluginEnvironmentAcquireLeaseParams,
): Promise<PluginEnvironmentLease> {
const config = parseDriverConfig(params.config);
const client = createModalClient(config);
try {
const app = await resolveApp(client, config);
const tags = buildSandboxTags({
companyId: params.companyId,
environmentId: params.environmentId,
runId: params.runId,
reuseLease: config.reuseLease,
});
const sandbox = await createSandboxFor(client, app, config, tags);
try {
await ensureRemoteWorkspace(sandbox, config.workdir);
return {
providerLeaseId: sandbox.sandboxId,
metadata: leaseMetadata({
config,
sandbox,
remoteCwd: config.workdir,
resumedLease: false,
}),
};
} catch (error) {
await sandbox.terminate().catch(() => undefined);
throw error;
}
} finally {
// Keep the client open for the lease lifetime is unnecessary; subsequent
// calls construct their own client. Close the local handle to free
// grpc resources.
client.close();
}
},
async onEnvironmentResumeLease(
params: PluginEnvironmentResumeLeaseParams,
): Promise<PluginEnvironmentLease> {
const config = parseDriverConfig(params.config);
const client = createModalClient(config);
try {
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
if (!sandbox) {
return { providerLeaseId: null, metadata: { expired: true } };
}
try {
await ensureRemoteWorkspace(sandbox, config.workdir);
return {
providerLeaseId: sandbox.sandboxId,
metadata: leaseMetadata({ config, sandbox, remoteCwd: config.workdir, resumedLease: true }),
};
} catch (error) {
// If we just resumed and workspace setup blew up, treat as a lease
// failure rather than silently terminating the user's reusable
// sandbox. Detach so the sandbox is not killed for a transient setup
// error.
void sandbox.detach();
throw error;
}
} finally {
client.close();
}
},
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
const client = createModalClient(config);
try {
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
if (!sandbox) return;
if (config.reuseLease) {
// Modal has no separate pause primitive. Detaching releases the local
// grpc connection but leaves the sandbox running on Modal until its
// configured sandboxTimeoutMs or idleTimeoutMs expires. The next
// acquire/resume reconnects via sandboxes.fromId(providerLeaseId).
void sandbox.detach();
return;
}
await sandbox.terminate();
} finally {
client.close();
}
},
async onEnvironmentDestroyLease(
params: PluginEnvironmentDestroyLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
const client = createModalClient(config);
try {
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
if (!sandbox) return;
await sandbox.terminate();
} finally {
client.close();
}
},
async onEnvironmentRealizeWorkspace(
params: PluginEnvironmentRealizeWorkspaceParams,
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
const config = parseDriverConfig(params.config);
const fallback =
params.workspace.remotePath ??
params.workspace.localPath ??
config.workdir;
const remoteCwd = leaseRemoteCwd(params.lease.metadata, fallback);
if (params.lease.providerLeaseId) {
const client = createModalClient(config);
try {
const sandbox = await getSandboxOrNull(client, params.lease.providerLeaseId);
if (sandbox) {
await ensureRemoteWorkspace(sandbox, remoteCwd);
}
} finally {
client.close();
}
}
return {
cwd: remoteCwd,
metadata: { provider: "modal", remoteCwd },
};
},
async onEnvironmentExecute(
params: PluginEnvironmentExecuteParams,
): Promise<PluginEnvironmentExecuteResult> {
if (!params.lease.providerLeaseId) {
return {
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "No provider lease ID available for execution.",
};
}
const config = parseDriverConfig(params.config);
const client = createModalClient(config);
const callerTimeoutMs =
params.timeoutMs != null && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
? Math.max(1000, Math.trunc(params.timeoutMs / 1000) * 1000)
: config.execTimeoutMs;
try {
const sandbox = await getSandboxOrNull(client, params.lease.providerLeaseId);
if (!sandbox) {
return {
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "Modal sandbox lease is no longer available.\n",
};
}
const stdinPath = params.stdin != null
? `/tmp/paperclip-stdin-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
: null;
try {
if (stdinPath && params.stdin != null) {
await stageStdin(sandbox, params.stdin, stdinPath);
}
const script = buildLoginShellScript({
command: params.command,
args: params.args ?? [],
cwd: params.cwd ?? config.workdir,
env: params.env,
stdinPath: stdinPath ?? undefined,
});
const proc = await sandbox.exec(["sh", "-lc", script], {
timeoutMs: callerTimeoutMs,
stdout: "pipe",
stderr: "pipe",
});
const { stdout, stderr, exitCode } = await readProcessStreams(proc);
return {
exitCode,
timedOut: false,
stdout,
stderr,
};
} catch (error) {
if (error instanceof TimeoutError || error instanceof SandboxTimeoutError) {
return {
exitCode: null,
timedOut: true,
stdout: "",
stderr: `${formatErrorMessage(error)}\n`,
};
}
throw error;
} finally {
if (stdinPath) {
await deleteStdinPath(sandbox, stdinPath);
}
}
} finally {
client.close();
}
},
});
export default plugin;
@@ -0,0 +1,5 @@
import { runWorker } from "@paperclipai/plugin-sdk";
import plugin from "./plugin.js";
export default plugin;
runWorker(plugin, import.meta.url);
@@ -0,0 +1,11 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023"],
"types": ["node"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
});