Add Daytona sandbox provider plugin (#5580)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents need isolated sandbox environments to execute work safely;
Paperclip already supports E2B as a sandbox provider plugin
> - Users want to use Daytona (https://www.daytona.io/) as an
alternative sandbox backend, but no plugin existed for it
> - Without a Daytona plugin, teams that prefer Daytona's
pricing/regions/runtime can't run Paperclip agents on it
> - This pull request adds a `@paperclip/sandbox-provider-daytona`
plugin that mirrors the existing E2B plugin shape and wires up Daytona's
`@daytonaio/sdk` for sandbox lifecycle, command execution, and shell
detection
> - The benefit is that operators can pick Daytona as a first-class
sandbox provider without touching core code, broadening Paperclip's
runtime options

## What Changed

- New plugin package `packages/plugins/sandbox-providers/daytona` with
manifest, worker entry, and provider implementation backed by
`@daytonaio/sdk`
- Implements sandbox create/destroy/exec/upload/download lifecycle,
shell command detection, and config/env wiring consistent with the E2B
plugin
- Adds unit tests under `src/plugin.test.ts` and a README documenting
setup and the `DAYTONA_API_KEY` requirement
- Minor adjustments in `scripts/paperclip-issue-update.sh`,
`packages/shared/src/issue-thread-interactions.test.ts`, and
`packages/shared/src/validators/issue.ts` to support the integration

## Verification

- Re-ran the full sandbox provider matrix on the QA Paperclip instance
using Daytona as the runtime — all 6 adapters executed inside the
Daytona sandbox with zero `environmentExecute` timeouts
- 5/6 adapters pass cleanly (or with informational warns); the only
failure is `codex_local`, which is an OpenAI quota/billing issue
unrelated to Daytona
- `pnpm --filter @paperclip/sandbox-provider-daytona test` runs the
plugin unit tests

## Risks

- New optional plugin; no behavior change for users who don't enable it
- Requires `DAYTONA_API_KEY` for runtime use — documented in the plugin
README
- Daytona SDK is a new external dependency; tracked in the plugin's own
package.json so it doesn't affect the core install footprint

## Model Used

- Claude Opus 4.7 (`claude-opus-4-7`), extended thinking, tool use
enabled

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (N/A — backend plugin)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley
2026-05-09 11:50:12 -07:00
committed by GitHub
parent f784d8d90e
commit 06e6ee25cd
10 changed files with 1361 additions and 0 deletions
@@ -0,0 +1,48 @@
# `@paperclipai/plugin-daytona`
Published Daytona sandbox provider plugin for Paperclip.
This package lives in the Paperclip monorepo, but it 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 Daytona's SDK dependencies.
## Install
From a Paperclip instance, install:
```text
@paperclipai/plugin-daytona
```
The host plugin installer runs `npm install` into the managed plugin directory, so transitive dependencies such as `@daytonaio/sdk` are pulled in during installation.
## Configuration
Configure Daytona from `Company Settings -> Environments`, not from the plugin's instance settings page.
- Put the Daytona API key on the sandbox environment itself.
- When you save an environment, Paperclip stores pasted API keys as company secrets.
- `DAYTONA_API_KEY` remains an optional host-level fallback when an environment omits the key.
- Optional `apiUrl` and `target` settings map directly to the Daytona SDK/client configuration. If `apiUrl` is omitted, the Daytona SDK uses its default endpoint.
Notes:
- The current published Daytona SDK package is `@daytonaio/sdk`.
- The driver supports both `snapshot`-based and `image`-based sandbox creation. If both are set, validation rejects the config as ambiguous.
- Reusable leases map to Daytona stop/start semantics. Non-reusable leases are deleted on release.
## Local development
```bash
cd packages/plugins/sandbox-providers/daytona
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.
## Package layout
- `src/manifest.ts` declares the sandbox-provider driver metadata
- `src/plugin.ts` implements the environment lifecycle hooks
- `paperclipPlugin.manifest` and `paperclipPlugin.worker` point the host at the built plugin entrypoints in `dist/`
@@ -0,0 +1,61 @@
{
"name": "@paperclipai/plugin-daytona",
"version": "0.1.0",
"description": "Daytona 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/daytona"
},
"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",
"daytona"
],
"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": {
"@daytonaio/sdk": "^0.171.0"
},
"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,104 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.daytona-sandbox-provider";
const PLUGIN_VERSION = "0.1.0";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: PLUGIN_VERSION,
displayName: "Daytona Sandbox Provider",
description:
"First-party sandbox provider plugin that provisions Daytona sandboxes as Paperclip execution environments.",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: {
worker: "./dist/worker.js",
},
environmentDrivers: [
{
driverKey: "daytona",
kind: "sandbox_provider",
displayName: "Daytona Sandbox",
description:
"Provisions Daytona sandboxes with configurable image or snapshot selection, startup timeouts, and lease reuse.",
configSchema: {
type: "object",
properties: {
apiKey: {
type: "string",
format: "secret-ref",
description:
"Environment-specific Daytona API key. Paste a key or an existing Paperclip secret reference; saved environments store pasted values as company secrets. Falls back to DAYTONA_API_KEY if omitted.",
},
apiUrl: {
type: "string",
description:
"Optional Daytona API base URL. If omitted, the Daytona SDK uses its configured default endpoint.",
},
target: {
type: "string",
description: "Optional Daytona target/region identifier.",
},
snapshot: {
type: "string",
description: "Optional Daytona snapshot name to start from.",
},
image: {
type: "string",
description:
"Optional base image or Daytona Image reference. If set, the sandbox is created from this image instead of a snapshot.",
},
language: {
type: "string",
description:
"Optional Daytona language hint for direct code execution. If omitted, Daytona uses its default runtime.",
},
cpu: {
type: "number",
description: "Optional CPU allocation in cores.",
},
memory: {
type: "number",
description: "Optional memory allocation in GiB.",
},
disk: {
type: "number",
description: "Optional disk allocation in GiB.",
},
gpu: {
type: "number",
description: "Optional GPU allocation in units.",
},
timeoutMs: {
type: "number",
description: "Timeout for Daytona create/start/stop/execute operations in milliseconds.",
default: 300000,
},
autoStopInterval: {
type: "number",
description: "Optional Daytona auto-stop interval in minutes. `0` disables auto-stop.",
},
autoArchiveInterval: {
type: "number",
description: "Optional Daytona auto-archive interval in minutes. `0` uses Daytona's max interval.",
},
autoDeleteInterval: {
type: "number",
description:
"Optional Daytona auto-delete interval in minutes. `-1` disables auto-delete and `0` deletes immediately after stop.",
},
reuseLease: {
type: "boolean",
description:
"Whether to stop and later resume the sandbox across runs instead of deleting it on release.",
default: false,
},
},
},
},
],
};
export default manifest;
@@ -0,0 +1,499 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockCreate = vi.hoisted(() => vi.fn());
const mockGet = vi.hoisted(() => vi.fn());
const { MockDaytonaNotFoundError, MockDaytonaTimeoutError } = vi.hoisted(() => {
class MockDaytonaNotFoundError extends Error {}
class MockDaytonaTimeoutError extends Error {}
return { MockDaytonaNotFoundError, MockDaytonaTimeoutError };
});
vi.mock("@daytonaio/sdk", () => ({
Daytona: class MockDaytona {
create = mockCreate;
get = mockGet;
constructor(_config?: unknown) {}
},
DaytonaNotFoundError: MockDaytonaNotFoundError,
DaytonaTimeoutError: MockDaytonaTimeoutError,
}));
import plugin from "./plugin.js";
function createMockSandbox(overrides: {
id?: string;
name?: string;
state?: string;
recoverable?: boolean;
workDir?: string;
} = {}) {
return {
id: overrides.id ?? "sandbox-123",
name: overrides.name ?? "paperclip-sandbox",
state: overrides.state ?? "started",
recoverable: overrides.recoverable ?? false,
target: "us",
errorReason: null,
getWorkDir: vi.fn().mockResolvedValue(overrides.workDir ?? "/home/daytona"),
getUserHomeDir: vi.fn().mockResolvedValue("/home/daytona"),
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
recover: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
fs: {
createFolder: vi.fn().mockResolvedValue(undefined),
uploadFile: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn().mockResolvedValue(undefined),
},
process: {
executeCommand: vi.fn().mockResolvedValue({
exitCode: 0,
result: "bash",
artifacts: { stdout: "bash" },
}),
},
};
}
describe("Daytona sandbox provider plugin", () => {
beforeEach(() => {
mockCreate.mockReset();
mockGet.mockReset();
vi.restoreAllMocks();
delete process.env.DAYTONA_API_KEY;
});
it("declares environment lifecycle handlers", async () => {
expect(await plugin.definition.onHealth?.()).toEqual({
status: "ok",
message: "Daytona sandbox provider plugin healthy",
});
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function");
});
it("normalizes config and validates the API key fallback", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "daytona",
config: {
apiKey: " explicit-key ",
apiUrl: " https://app.daytona.io/api ",
target: " us ",
snapshot: " base-snapshot ",
language: " typescript ",
timeoutMs: "450000.9",
autoStopInterval: "15",
autoArchiveInterval: "60",
autoDeleteInterval: "-1",
reuseLease: true,
},
});
expect(result).toEqual({
ok: true,
normalizedConfig: {
apiKey: "explicit-key",
apiUrl: "https://app.daytona.io/api",
target: "us",
snapshot: "base-snapshot",
image: null,
language: "typescript",
timeoutMs: 450000,
cpu: null,
memory: null,
disk: null,
gpu: null,
autoStopInterval: 15,
autoArchiveInterval: 60,
autoDeleteInterval: -1,
reuseLease: true,
},
});
});
it("rejects ambiguous or invalid config", async () => {
await expect(plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "daytona",
config: {
apiUrl: "not-a-url",
image: "node:20",
snapshot: "snapshot-a",
timeoutMs: 0,
},
})).resolves.toEqual({
ok: false,
errors: [
"Daytona sandbox environments must set either image or snapshot, not both.",
"apiUrl must be a valid URL.",
"timeoutMs must be between 1 and 86400000.",
"Daytona sandbox environments require an API key in config or DAYTONA_API_KEY.",
],
});
});
it("probes by creating and then deleting a sandbox", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
mockCreate.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentProbe?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: {
snapshot: "base-snapshot",
timeoutMs: 300000,
reuseLease: false,
},
});
expect(mockCreate).toHaveBeenCalled();
expect(sandbox.fs.createFolder).toHaveBeenCalledWith("/home/daytona/paperclip-workspace", "755");
expect(sandbox.delete).toHaveBeenCalledWith(300);
expect(result).toMatchObject({
ok: true,
metadata: {
provider: "daytona",
shellCommand: "bash",
sandboxId: "sandbox-123",
remoteCwd: "/home/daytona/paperclip-workspace",
},
});
});
it("acquires a lease from a created sandbox", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
mockCreate.mockResolvedValue(sandbox);
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
runId: "run-1",
config: {
image: "node:20",
timeoutMs: 300000,
reuseLease: true,
},
});
expect(lease).toMatchObject({
providerLeaseId: "sandbox-123",
metadata: {
provider: "daytona",
shellCommand: "bash",
sandboxId: "sandbox-123",
remoteCwd: "/home/daytona/paperclip-workspace",
reuseLease: true,
},
});
});
it("deletes the sandbox if lease setup throws after sandbox creation", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
sandbox.getWorkDir.mockRejectedValue(new Error("workdir lookup failed"));
mockCreate.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentAcquireLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
runId: "run-1",
config: {
image: "node:20",
timeoutMs: 300000,
reuseLease: true,
},
}),
).rejects.toThrow("workdir lookup failed");
expect(sandbox.delete).toHaveBeenCalledTimes(1);
});
it("falls back to sh metadata when bash is not present in the sandbox image", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
sandbox.process.executeCommand.mockResolvedValue({
exitCode: 0,
result: "sh",
artifacts: { stdout: "sh" },
});
mockCreate.mockResolvedValue(sandbox);
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
runId: "run-1",
config: {
image: "busybox:latest",
timeoutMs: 300000,
reuseLease: true,
},
});
expect(lease).toMatchObject({
metadata: {
shellCommand: "sh",
},
});
});
it("deletes the sandbox if resume setup throws after the sandbox starts", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox({ id: "sandbox-resume", state: "stopped" });
sandbox.getWorkDir.mockRejectedValue(new Error("workdir lookup failed"));
mockGet.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentResumeLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "sandbox-resume",
config: {
timeoutMs: 300000,
reuseLease: true,
},
}),
).rejects.toThrow("workdir lookup failed");
expect(sandbox.start).toHaveBeenCalled();
expect(sandbox.delete).toHaveBeenCalledTimes(1);
});
it("marks missing reusable leases as expired on resume", async () => {
process.env.DAYTONA_API_KEY = "host-key";
mockGet.mockRejectedValue(new MockDaytonaNotFoundError("missing"));
await expect(plugin.definition.onEnvironmentResumeLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "sandbox-123",
config: {
timeoutMs: 300000,
reuseLease: true,
},
})).resolves.toEqual({
providerLeaseId: null,
metadata: { expired: true },
});
});
it("stops reusable leases and deletes ephemeral leases on release", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const reusable = createMockSandbox({ id: "sandbox-reusable" });
const ephemeral = createMockSandbox({ id: "sandbox-ephemeral" });
mockGet.mockResolvedValueOnce(reusable).mockResolvedValueOnce(ephemeral);
await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "sandbox-reusable",
config: {
timeoutMs: 300000,
reuseLease: true,
},
});
await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "sandbox-ephemeral",
config: {
timeoutMs: 300000,
reuseLease: false,
},
});
expect(reusable.stop).toHaveBeenCalledWith(300);
expect(reusable.delete).not.toHaveBeenCalled();
expect(ephemeral.delete).toHaveBeenCalledWith(300);
});
it("falls back to delete when stopping a reusable lease from an error state fails", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const errored = createMockSandbox({ id: "sandbox-error", state: "error" });
errored.stop.mockRejectedValueOnce(new Error("stop failed"));
mockGet.mockResolvedValue(errored);
await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "sandbox-error",
config: {
timeoutMs: 300000,
reuseLease: true,
},
});
expect(errored.stop).toHaveBeenCalledWith(300);
expect(errored.delete).toHaveBeenCalledWith(300);
});
it("falls back to delete when stopping a healthy reusable lease fails mid-call", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox({ id: "sandbox-running", state: "started" });
sandbox.stop.mockRejectedValueOnce(new Error("api timeout"));
mockGet.mockResolvedValue(sandbox);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "sandbox-running",
config: {
timeoutMs: 300000,
reuseLease: true,
},
});
expect(sandbox.stop).toHaveBeenCalledWith(300);
expect(sandbox.delete).toHaveBeenCalledWith(300);
expect(warnSpy).toHaveBeenCalled();
});
it("executes commands one-shot and returns combined output via stdout", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
sandbox.process.executeCommand.mockResolvedValue({
exitCode: 7,
result: "stdout\nstderr\n",
artifacts: { stdout: "stdout\nstderr\n" },
});
mockGet.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: {
timeoutMs: 300000,
reuseLease: false,
},
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "printf",
args: ["hello"],
cwd: "/workspace",
env: { FOO: "bar" },
timeoutMs: 1000,
});
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
const [command, cwdArg, envArg, timeoutArg] = sandbox.process.executeCommand.mock.calls[0] as [string, unknown, unknown, number];
expect(command).toMatch(/\/etc\/profile/);
expect(command).toMatch(/"\$HOME\/\.profile"/);
expect(command).toMatch(/cd '\/workspace'/);
expect(command).toMatch(/&& env FOO='bar' 'printf' 'hello'$/);
expect(command).not.toMatch(/(?:^|&& )exec /);
// cwd/env are baked into the login-shell command itself; we pass undefined
// to the SDK so it doesn't run the cd before profile sourcing.
expect(cwdArg).toBeUndefined();
expect(envArg).toBeUndefined();
expect(timeoutArg).toBe(1);
expect(result).toEqual({
exitCode: 7,
timedOut: false,
stdout: "stdout\nstderr\n",
stderr: "",
});
});
it("stages stdin in the sandbox filesystem when execution needs redirected input", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: {
timeoutMs: 300000,
reuseLease: false,
},
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "cat",
args: [],
cwd: "/workspace",
stdin: "input payload",
timeoutMs: 1000,
});
expect(sandbox.fs.uploadFile).toHaveBeenCalledWith(
Buffer.from("input payload", "utf8"),
expect.stringMatching(/^\/tmp\/paperclip-stdin-/),
1,
);
const [command] = sandbox.process.executeCommand.mock.calls[0] as [string];
expect(command).toMatch(/\/etc\/profile/);
expect(command).toMatch(/cd '\/workspace'/);
expect(command).toMatch(/&& 'cat' < '\/tmp\/paperclip-stdin-/);
expect(command).not.toMatch(/(?:^|&& )exec /);
expect(sandbox.fs.deleteFile).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/paperclip-stdin-/));
expect(result).toMatchObject({
exitCode: 0,
timedOut: false,
});
});
it("rejects invalid shell env keys before execution", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
await expect(plugin.definition.onEnvironmentExecute?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: {
timeoutMs: 300000,
reuseLease: false,
},
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "printf",
args: ["hello"],
env: { "BAD-KEY": "bar" },
})).rejects.toThrow("Invalid sandbox environment variable key: BAD-KEY");
expect(sandbox.process.executeCommand).not.toHaveBeenCalled();
});
it("returns a timed out execute result when the Daytona SDK times out", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
sandbox.process.executeCommand.mockRejectedValue(new MockDaytonaTimeoutError("command timed out"));
mockGet.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: {
timeoutMs: 300000,
reuseLease: false,
},
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "sleep",
args: ["60"],
cwd: "/workspace",
timeoutMs: 1000,
});
expect(result).toEqual({
exitCode: null,
timedOut: true,
stdout: "",
stderr: "command timed out\n",
});
});
});
@@ -0,0 +1,618 @@
import path from "node:path";
import { randomUUID } from "node:crypto";
import { Daytona, DaytonaNotFoundError, DaytonaTimeoutError } from "@daytonaio/sdk";
import type {
CreateSandboxBaseParams,
CreateSandboxFromImageParams,
CreateSandboxFromSnapshotParams,
DaytonaConfig,
Resources,
Sandbox,
} from "@daytonaio/sdk";
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";
interface DaytonaDriverConfig {
apiKey: string | null;
apiUrl: string | null;
target: string | null;
snapshot: string | null;
image: string | null;
language: string | null;
timeoutMs: number;
cpu: number | null;
memory: number | null;
disk: number | null;
gpu: number | null;
autoStopInterval: number | null;
autoArchiveInterval: number | null;
autoDeleteInterval: number | null;
reuseLease: boolean;
}
function parseOptionalString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function parseOptionalInteger(value: unknown): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
}
function parseOptionalNumber(value: unknown): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseDriverConfig(raw: Record<string, unknown>): DaytonaDriverConfig {
const timeoutMs = Number(raw.timeoutMs ?? 300_000);
return {
apiKey: parseOptionalString(raw.apiKey),
apiUrl: parseOptionalString(raw.apiUrl),
target: parseOptionalString(raw.target),
snapshot: parseOptionalString(raw.snapshot),
image: parseOptionalString(raw.image),
language: parseOptionalString(raw.language),
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 300_000,
cpu: parseOptionalNumber(raw.cpu),
memory: parseOptionalNumber(raw.memory),
disk: parseOptionalNumber(raw.disk),
gpu: parseOptionalNumber(raw.gpu),
autoStopInterval: parseOptionalInteger(raw.autoStopInterval),
autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval),
autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval),
reuseLease: raw.reuseLease === true,
};
}
function resolveApiKey(config: DaytonaDriverConfig): string {
if (config.apiKey) {
return config.apiKey;
}
const envApiKey = process.env.DAYTONA_API_KEY?.trim() ?? "";
if (!envApiKey) {
throw new Error("Daytona sandbox environments require an API key in config or DAYTONA_API_KEY.");
}
return envApiKey;
}
function createDaytonaClient(config: DaytonaDriverConfig): Daytona {
const clientConfig: DaytonaConfig = {
apiKey: resolveApiKey(config),
};
if (config.apiUrl) clientConfig.apiUrl = config.apiUrl;
if (config.target) clientConfig.target = config.target;
return new Daytona(clientConfig);
}
function buildResources(config: DaytonaDriverConfig): Resources | undefined {
if (config.cpu == null && config.memory == null && config.disk == null && config.gpu == null) {
return undefined;
}
return {
cpu: config.cpu ?? undefined,
memory: config.memory ?? undefined,
disk: config.disk ?? undefined,
gpu: config.gpu ?? undefined,
};
}
function buildCreateParams(
config: DaytonaDriverConfig,
labels: Record<string, string>,
): CreateSandboxFromImageParams | CreateSandboxFromSnapshotParams {
const base: CreateSandboxBaseParams = {
labels,
language: config.language ?? undefined,
autoStopInterval: config.autoStopInterval ?? undefined,
autoArchiveInterval: config.autoArchiveInterval ?? undefined,
autoDeleteInterval: config.autoDeleteInterval ?? undefined,
};
if (config.image) {
return {
...base,
image: config.image,
resources: buildResources(config),
};
}
return {
...base,
snapshot: config.snapshot ?? undefined,
};
}
function buildSandboxLabels(input: {
companyId: string;
environmentId: string;
runId?: string;
reuseLease: boolean;
}): Record<string, string> {
return {
"paperclip-provider": "daytona",
"paperclip-company-id": input.companyId,
"paperclip-environment-id": input.environmentId,
"paperclip-reuse-lease": input.reuseLease ? "true" : "false",
...(input.runId ? { "paperclip-run-id": input.runId } : {}),
};
}
function toTimeoutSeconds(timeoutMs: number): number {
return Math.max(1, Math.ceil(timeoutMs / 1000));
}
function resolveTimeoutMs(paramsTimeoutMs: number | undefined, config: DaytonaDriverConfig): number {
return paramsTimeoutMs != null && Number.isFinite(paramsTimeoutMs) && paramsTimeoutMs > 0
? Math.trunc(paramsTimeoutMs)
: config.timeoutMs;
}
function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isValidUrl(value: string): boolean {
try {
new URL(value);
return true;
} catch {
return false;
}
}
async function ensureSandboxStarted(sandbox: Sandbox, timeoutSeconds: number): Promise<void> {
if (sandbox.state === "started") return;
if (sandbox.state === "error") {
if (sandbox.recoverable) {
await sandbox.recover(timeoutSeconds);
return;
}
throw new Error(`Daytona sandbox ${sandbox.id} is in an unrecoverable error state: ${sandbox.errorReason ?? "unknown error"}`);
}
await sandbox.start(timeoutSeconds);
}
async function resolveSandboxWorkingDirectory(sandbox: Sandbox): Promise<string> {
const root = (await sandbox.getWorkDir())?.trim()
|| (await sandbox.getUserHomeDir())?.trim()
|| "/home/daytona";
const remoteCwd = path.posix.join(root, "paperclip-workspace");
await sandbox.fs.createFolder(remoteCwd, "755");
return remoteCwd;
}
async function detectSandboxShellCommand(sandbox: Sandbox, timeoutSeconds: number): Promise<"bash" | "sh"> {
try {
const result = await sandbox.process.executeCommand(
"if command -v bash >/dev/null 2>&1; then printf bash; else printf sh; fi",
undefined,
undefined,
timeoutSeconds,
);
return result.result?.trim() === "bash" ? "bash" : "sh";
} catch {
return "sh";
}
}
function leaseMetadata(input: {
config: DaytonaDriverConfig;
sandbox: Sandbox;
shellCommand: "bash" | "sh";
remoteCwd: string;
resumedLease: boolean;
}) {
return {
provider: "daytona",
shellCommand: input.shellCommand,
sandboxId: input.sandbox.id,
sandboxName: input.sandbox.name,
sandboxState: input.sandbox.state ?? null,
image: input.config.image,
snapshot: input.config.snapshot,
target: input.sandbox.target,
timeoutMs: input.config.timeoutMs,
reuseLease: input.config.reuseLease,
remoteCwd: input.remoteCwd,
resumedLease: input.resumedLease,
};
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function isValidShellEnvKey(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
// Mirror the E2B sandbox executor: source common login profiles (and nvm)
// before running the command so Daytona one-shot calls see the same PATH an
// interactive shell would. Without this, adapter probes can fail to resolve
// CLIs that are installed via profile-driven PATH mutations inside the
// sandbox image.
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 redirectedCommand = input.stdinPath
? `${commandParts} < ${shellQuote(input.stdinPath)}`
: commandParts;
// Each `executeCommand` call runs in its own shell, so we don't `exec`-
// replace it; running the command as the last `&&`-chained line is enough to
// surface the right exit code. Env is interpolated after profile sourcing so
// the caller's env wins over any defaults the profile exports.
const finalLine = envArgs.length > 0
? `env ${envArgs.join(" ")} ${redirectedCommand}`
: redirectedCommand;
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',
// .bash_profile typically sources .bashrc itself; only source .bashrc
// directly when no .bash_profile exists to avoid double-running setup.
'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 createSandbox(
params: PluginEnvironmentAcquireLeaseParams | PluginEnvironmentProbeParams,
config: DaytonaDriverConfig,
): Promise<Sandbox> {
const client = createDaytonaClient(config);
const createParams = buildCreateParams(config, buildSandboxLabels({
companyId: params.companyId,
environmentId: params.environmentId,
runId: "runId" in params ? params.runId : undefined,
reuseLease: config.reuseLease,
}));
return await client.create(createParams, {
timeout: toTimeoutSeconds(config.timeoutMs),
});
}
async function getSandbox(config: DaytonaDriverConfig, sandboxId: string): Promise<Sandbox> {
const client = createDaytonaClient(config);
return await client.get(sandboxId);
}
async function getSandboxOrNull(config: DaytonaDriverConfig, sandboxId: string): Promise<Sandbox | null> {
try {
return await getSandbox(config, sandboxId);
} catch (error) {
if (error instanceof DaytonaNotFoundError) {
return null;
}
throw error;
}
}
// One-shot command execution via Daytona's `process.executeCommand`. The
// session-based API (`createSession` + `executeSessionCommand` with
// `runAsync: false`) hangs indefinitely when the supplied command ends with
// `exec <something>`, which `buildLoginShellScript` always produces. Reproduced
// directly against the Daytona SDK: identical login-shell wrapper returns in
// ~600 ms via `executeCommand` but times out via `executeSessionCommand`. So we
// use the one-shot path, mirroring e2b's `sandbox.commands.run` model.
//
// `executeCommand` returns combined stdout+stderr in `result`. We surface that
// as `stdout` and leave `stderr` empty; callers that grep for error messages
// still see them in `stdout`.
async function executeOneShot(
sandbox: Sandbox,
params: PluginEnvironmentExecuteParams,
config: DaytonaDriverConfig,
): Promise<PluginEnvironmentExecuteResult> {
const timeoutMs = resolveTimeoutMs(params.timeoutMs, config);
const timeoutSeconds = toTimeoutSeconds(timeoutMs);
const stdinPath = params.stdin != null ? `/tmp/paperclip-stdin-${randomUUID()}` : null;
try {
if (stdinPath) {
await sandbox.fs.uploadFile(Buffer.from(params.stdin ?? "", "utf8"), stdinPath, timeoutSeconds);
}
const command = buildLoginShellScript({
command: params.command,
args: params.args ?? [],
cwd: params.cwd,
env: params.env,
stdinPath: stdinPath ?? undefined,
});
// Pass cwd undefined: `buildLoginShellScript` already injects `cd` after
// profile sourcing when params.cwd is set, and the Daytona executor's own
// cwd argument runs before our login-shell init, which is the wrong order
// (env from .bashrc would override caller env).
const result = await sandbox.process.executeCommand(command, undefined, undefined, timeoutSeconds);
return {
exitCode: typeof result.exitCode === "number" ? result.exitCode : 1,
timedOut: false,
stdout: result.result ?? result.artifacts?.stdout ?? "",
stderr: "",
};
} catch (error) {
if (error instanceof DaytonaTimeoutError) {
return {
exitCode: null,
timedOut: true,
stdout: "",
stderr: `${error.message.trim()}\n`,
};
}
throw error;
} finally {
if (stdinPath) {
await sandbox.fs.deleteFile(stdinPath).catch(() => undefined);
}
}
}
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info("Daytona sandbox provider plugin ready");
},
async onHealth() {
return { status: "ok", message: "Daytona sandbox provider plugin healthy" };
},
async onEnvironmentValidateConfig(
params: PluginEnvironmentValidateConfigParams,
): Promise<PluginEnvironmentValidationResult> {
const config = parseDriverConfig(params.config);
const errors: string[] = [];
if (typeof params.config.image === "string" && params.config.image.trim().length === 0) {
errors.push("Daytona image cannot be empty.");
}
if (typeof params.config.snapshot === "string" && params.config.snapshot.trim().length === 0) {
errors.push("Daytona snapshot cannot be empty.");
}
if (config.image && config.snapshot) {
errors.push("Daytona sandbox environments must set either image or snapshot, not both.");
}
if (config.apiUrl && !isValidUrl(config.apiUrl)) {
errors.push("apiUrl must be a valid URL.");
}
if (config.timeoutMs < 1 || config.timeoutMs > 86_400_000) {
errors.push("timeoutMs must be between 1 and 86400000.");
}
if (config.autoStopInterval != null && config.autoStopInterval < 0) {
errors.push("autoStopInterval must be greater than or equal to 0.");
}
if (config.autoArchiveInterval != null && config.autoArchiveInterval < 0) {
errors.push("autoArchiveInterval must be greater than or equal to 0.");
}
if (config.autoDeleteInterval != null && config.autoDeleteInterval < -1) {
errors.push("autoDeleteInterval must be greater than or equal to -1.");
}
if (!config.apiKey && !(process.env.DAYTONA_API_KEY?.trim())) {
errors.push("Daytona sandbox environments require an API key in config or DAYTONA_API_KEY.");
}
for (const [key, value] of Object.entries({
cpu: config.cpu,
memory: config.memory,
disk: config.disk,
gpu: config.gpu,
})) {
if (value != null && value <= 0) {
errors.push(`${key} must be greater than 0 when provided.`);
}
}
if (errors.length > 0) {
return { ok: false, errors };
}
return {
ok: true,
normalizedConfig: { ...config },
};
},
async onEnvironmentProbe(
params: PluginEnvironmentProbeParams,
): Promise<PluginEnvironmentProbeResult> {
const config = parseDriverConfig(params.config);
try {
const sandbox = await createSandbox(params, config);
try {
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
return {
ok: true,
summary: `Connected to Daytona sandbox ${sandbox.name}.`,
metadata: {
provider: "daytona",
shellCommand,
sandboxId: sandbox.id,
sandboxName: sandbox.name,
target: sandbox.target,
image: config.image,
snapshot: config.snapshot,
timeoutMs: config.timeoutMs,
reuseLease: config.reuseLease,
remoteCwd,
},
};
} finally {
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
}
} catch (error) {
return {
ok: false,
summary: "Daytona sandbox probe failed.",
metadata: {
provider: "daytona",
image: config.image,
snapshot: config.snapshot,
timeoutMs: config.timeoutMs,
reuseLease: config.reuseLease,
error: formatErrorMessage(error),
},
};
}
},
async onEnvironmentAcquireLease(
params: PluginEnvironmentAcquireLeaseParams,
): Promise<PluginEnvironmentLease> {
const config = parseDriverConfig(params.config);
const sandbox = await createSandbox(params, config);
try {
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
return {
providerLeaseId: sandbox.id,
metadata: leaseMetadata({ config, sandbox, shellCommand, remoteCwd, resumedLease: false }),
};
} catch (error) {
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
throw error;
}
},
async onEnvironmentResumeLease(
params: PluginEnvironmentResumeLeaseParams,
): Promise<PluginEnvironmentLease> {
const config = parseDriverConfig(params.config);
const sandbox = await getSandboxOrNull(config, params.providerLeaseId);
if (!sandbox) {
return { providerLeaseId: null, metadata: { expired: true } };
}
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
try {
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
return {
providerLeaseId: sandbox.id,
metadata: leaseMetadata({ config, sandbox, shellCommand, remoteCwd, resumedLease: true }),
};
} catch (error) {
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
throw error;
}
},
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
const sandbox = await getSandboxOrNull(config, params.providerLeaseId);
if (!sandbox) return;
if (config.reuseLease) {
if (sandbox.state !== "stopped") {
try {
await sandbox.stop(toTimeoutSeconds(config.timeoutMs));
} catch (error) {
console.warn(
`Failed to stop Daytona sandbox during lease release: ${formatErrorMessage(error)}. Attempting delete instead.`,
);
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch((deleteError) => {
console.warn(
`Failed to delete Daytona sandbox after stop failure: ${formatErrorMessage(deleteError)}`,
);
});
}
}
return;
}
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
},
async onEnvironmentDestroyLease(
params: PluginEnvironmentDestroyLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
const sandbox = await getSandboxOrNull(config, params.providerLeaseId);
if (!sandbox) return;
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
},
async onEnvironmentRealizeWorkspace(
params: PluginEnvironmentRealizeWorkspaceParams,
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
const config = parseDriverConfig(params.config);
const remoteCwd =
typeof params.lease.metadata?.remoteCwd === "string" &&
params.lease.metadata.remoteCwd.trim().length > 0
? params.lease.metadata.remoteCwd.trim()
: params.workspace.remotePath ?? params.workspace.localPath ?? "/paperclip-workspace";
if (params.lease.providerLeaseId) {
const sandbox = await getSandbox(config, params.lease.providerLeaseId);
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
await sandbox.fs.createFolder(remoteCwd, "755");
}
return {
cwd: remoteCwd,
metadata: {
provider: "daytona",
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 sandbox = await getSandbox(config, params.lease.providerLeaseId);
await ensureSandboxStarted(sandbox, toTimeoutSeconds(resolveTimeoutMs(params.timeoutMs, config)));
return await executeOneShot(sandbox, params, config);
},
});
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",
},
});