diff --git a/packages/plugins/sandbox-providers/daytona/README.md b/packages/plugins/sandbox-providers/daytona/README.md new file mode 100644 index 00000000..4d557ee2 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/README.md @@ -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/` diff --git a/packages/plugins/sandbox-providers/daytona/package.json b/packages/plugins/sandbox-providers/daytona/package.json new file mode 100644 index 00000000..d5039f58 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/package.json @@ -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" + } +} diff --git a/packages/plugins/sandbox-providers/daytona/src/index.ts b/packages/plugins/sandbox-providers/daytona/src/index.ts new file mode 100644 index 00000000..f7ce1cc1 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as plugin } from "./plugin.js"; diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts new file mode 100644 index 00000000..ba92c4e5 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -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; diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts new file mode 100644 index 00000000..389426a4 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -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", + }); + }); +}); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts new file mode 100644 index 00000000..2ea53ba6 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -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): 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, +): 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 { + 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 { + 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 { + 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; + 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 { + 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 { + const client = createDaytonaClient(config); + return await client.get(sandboxId); +} + +async function getSandboxOrNull(config: DaytonaDriverConfig, sandboxId: string): Promise { + 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 `, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/packages/plugins/sandbox-providers/daytona/src/worker.ts b/packages/plugins/sandbox-providers/daytona/src/worker.ts new file mode 100644 index 00000000..1e156024 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/worker.ts @@ -0,0 +1,5 @@ +import { runWorker } from "@paperclipai/plugin-sdk"; +import plugin from "./plugin.js"; + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/sandbox-providers/daytona/tsconfig.json b/packages/plugins/sandbox-providers/daytona/tsconfig.json new file mode 100644 index 00000000..000e3293 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2023"], + "types": ["node"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/plugins/sandbox-providers/daytona/vitest.config.ts b/packages/plugins/sandbox-providers/daytona/vitest.config.ts new file mode 100644 index 00000000..ce36a742 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index a81cf28f..180392bd 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -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",