forked from farhoodlabs/paperclip
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:
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -79,6 +79,11 @@
|
||||
"name": "@paperclipai/create-paperclip-plugin",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/daytona",
|
||||
"name": "@paperclipai/plugin-daytona",
|
||||
"publishFromCi": false
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/e2b",
|
||||
"name": "@paperclipai/plugin-e2b",
|
||||
|
||||
Reference in New Issue
Block a user