mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
## 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>
618 lines
21 KiB
TypeScript
618 lines
21 KiB
TypeScript
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;
|