mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 12:10:37 +09:00
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:
parent
f784d8d90e
commit
06e6ee25cd
10 changed files with 1361 additions and 0 deletions
618
packages/plugins/sandbox-providers/daytona/src/plugin.ts
Normal file
618
packages/plugins/sandbox-providers/daytona/src/plugin.ts
Normal file
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue