3027 lines
117 KiB
TypeScript
3027 lines
117 KiB
TypeScript
import path from "node:path";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { Daytona, DaytonaNotFoundError, DaytonaTimeoutError } from "@daytonaio/sdk";
|
|
import type {
|
|
CreateSandboxBaseParams,
|
|
CreateSandboxFromImageParams,
|
|
CreateSandboxFromSnapshotParams,
|
|
DaytonaConfig,
|
|
Resources,
|
|
Sandbox,
|
|
} from "@daytonaio/sdk";
|
|
import { decodeChannelBytes, definePlugin, NOOP_PLUGIN_TRACER } from "@paperclipai/plugin-sdk";
|
|
import type {
|
|
PluginContext,
|
|
PluginTracer,
|
|
PluginEnvironmentAcquireLeaseParams,
|
|
PluginEnvironmentCancelInteractiveSetupParams,
|
|
PluginEnvironmentCancelInteractiveSetupResult,
|
|
PluginEnvironmentCaptureTemplateParams,
|
|
PluginEnvironmentCaptureTemplateResult,
|
|
PluginEnvironmentDeleteTemplateParams,
|
|
PluginEnvironmentDeleteTemplateResult,
|
|
PluginEnvironmentDestroyLeaseParams,
|
|
PluginEnvironmentExecuteParams,
|
|
PluginEnvironmentExecuteResult,
|
|
PluginEnvironmentRunnerIngressEndpointParams,
|
|
PluginEnvironmentRunnerIngressEndpoint,
|
|
PluginEnvironmentGetInteractiveSetupParams,
|
|
PluginEnvironmentInteractiveSetupSession,
|
|
PluginEnvironmentLease,
|
|
PluginEnvironmentProbeParams,
|
|
PluginEnvironmentProbeResult,
|
|
PluginEnvironmentRealizeWorkspaceParams,
|
|
PluginEnvironmentRealizeWorkspaceResult,
|
|
PluginEnvironmentReleaseLeaseParams,
|
|
PluginEnvironmentResumeLeaseParams,
|
|
PluginEnvironmentStartInteractiveSetupParams,
|
|
PluginEnvironmentSyncInParams,
|
|
PluginEnvironmentSyncOutParams,
|
|
PluginEnvironmentSyncResult,
|
|
PluginEnvironmentValidateConfigParams,
|
|
PluginEnvironmentValidationResult,
|
|
PluginSyncOperation,
|
|
} from "@paperclipai/plugin-sdk";
|
|
import { performSyncIn, performSyncOut, withProviderSpan } from "./file-sync.js";
|
|
|
|
// The Claude `setup-token` login pseudo-terminal (PTY) session for this provider.
|
|
// The session runs the login command on a real pseudo-terminal, streams the
|
|
// terminal output, and delivers the delayed browser code plus the Enter byte. A
|
|
// later phase binds the opener to `sandbox.process` and wraps it with the
|
|
// `createLoginPtyTransport` factory from `@paperclipai/adapter-utils` to
|
|
// build the transport the login runner drives.
|
|
export {
|
|
createDaytonaLoginPtySessionOpener,
|
|
openDaytonaLoginPtySession,
|
|
createDaytonaLoginHomeFs,
|
|
} from "./login-pty.js";
|
|
export type {
|
|
LoginPtySession,
|
|
LoginPtySessionOpener,
|
|
LoginPtyLaunchDescriptor,
|
|
DaytonaPtyHandle,
|
|
DaytonaPtyProcess,
|
|
DaytonaPtyCreateOptions,
|
|
DaytonaLoginPtyOptions,
|
|
DaytonaLoginHomeFs,
|
|
} from "./login-pty.js";
|
|
import {
|
|
openDaytonaLoginPtySession as openLoginPtySession,
|
|
createDaytonaLoginHomeFs,
|
|
} from "./login-pty.js";
|
|
import type {
|
|
LoginPtySession as LoginPtyWorkerSession,
|
|
DaytonaPtyProcess,
|
|
DaytonaSandboxExec,
|
|
} from "./login-pty.js";
|
|
|
|
// The Daytona duplex command stream for the sandbox callback bridge. The channel
|
|
// runs the gateway command on a raw pseudo-terminal, streams the frames, and
|
|
// accepts host input. The worker resolves the sandbox by the provider lease id,
|
|
// registers the channel under the host route id, and streams the data and the
|
|
// exit through `ctx.duplexChannel`.
|
|
export {
|
|
createDaytonaDuplexChannelSessionOpener,
|
|
openDaytonaDuplexChannelSession,
|
|
buildDuplexChannelLaunchWrapper,
|
|
} from "./duplex-command-stream.js";
|
|
export type {
|
|
DuplexChannelSession,
|
|
DuplexChannelSessionOpener,
|
|
DaytonaDuplexChannelOptions,
|
|
} from "./duplex-command-stream.js";
|
|
import { openDaytonaDuplexChannelSession as openDuplexChannelSession } from "./duplex-command-stream.js";
|
|
import type { DuplexChannelSession } from "./duplex-command-stream.js";
|
|
|
|
// Injectable monotonic clock for provider-boundary timing (Open Q1). Defaults
|
|
// to the real wall clock; `plugin.test.ts` overrides it via
|
|
// `setDaytonaTimingClockForTest` so the measured `durationMs`/`getDurationMs`
|
|
// are deterministic. The timing path never calls `Date.now()` directly.
|
|
let timingNow: () => number = () => Date.now();
|
|
|
|
// The plugin context, hoisted to a module variable in `setup(ctx)`. The
|
|
// lifecycle hooks and the file-sync helpers have no closure over `ctx`, so they
|
|
// read the tracer through `getPluginTracer()`. Before `setup` runs (or in a
|
|
// test) the tracer is a no-op, so a span never throws.
|
|
let pluginContext: PluginContext | null = null;
|
|
|
|
/**
|
|
* Return the plugin tracer. It is the injected `ctx.tracer` after `setup`, or a
|
|
* no-op before it. A provider span opened through it records only when tracing
|
|
* is on and an active host trace context is present.
|
|
*/
|
|
export function getPluginTracer(): PluginTracer {
|
|
return pluginContext?.tracer ?? NOOP_PLUGIN_TRACER;
|
|
}
|
|
|
|
/**
|
|
* Test seam: set the module-level plugin context, and return a restore function.
|
|
* `plugin.test.ts` uses it to inject a recording tracer without running `setup`.
|
|
*/
|
|
export function __setDaytonaPluginContextForTest(ctx: PluginContext | null): () => void {
|
|
const previous = pluginContext;
|
|
pluginContext = ctx;
|
|
return () => {
|
|
pluginContext = previous;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Test seam: override the provider-timing clock and return a restore function.
|
|
* Not used in production, where the default wall clock always applies.
|
|
*/
|
|
export function setDaytonaTimingClockForTest(now: () => number): () => void {
|
|
const previous = timingNow;
|
|
timingNow = now;
|
|
return () => {
|
|
timingNow = previous;
|
|
};
|
|
}
|
|
|
|
// Injectable clock for the handle cache's freshness bookkeeping, deliberately
|
|
// kept separate from the provider-timing clock so tests can advance virtual time
|
|
// past a lease's auto-stop interval without perturbing the `getDurationMs` /
|
|
// `durationMs` measurements that ride on `timingNow`.
|
|
let handleFreshnessNow: () => number = () => Date.now();
|
|
|
|
/**
|
|
* Test seam: override the handle-cache freshness clock and return a restore
|
|
* function. Not used in production, where the default wall clock always applies.
|
|
*/
|
|
export function setDaytonaHandleFreshnessClockForTest(now: () => number): () => void {
|
|
const previous = handleFreshnessNow;
|
|
handleFreshnessNow = now;
|
|
return () => {
|
|
handleFreshnessNow = previous;
|
|
};
|
|
}
|
|
|
|
interface DaytonaDriverConfig {
|
|
apiKey: string | null;
|
|
apiUrl: string | null;
|
|
target: string | null;
|
|
snapshot: string | null;
|
|
image: string | null;
|
|
language: string | null;
|
|
timeoutMs: number;
|
|
livenessTimeoutMs: number;
|
|
cpu: number | null;
|
|
memory: number | null;
|
|
disk: number | null;
|
|
gpu: number | null;
|
|
autoStopInterval: number | null;
|
|
autoArchiveInterval: number | null;
|
|
autoDeleteInterval: number | null;
|
|
reuseLease: boolean;
|
|
archiveOnRelease: boolean;
|
|
}
|
|
|
|
type WorkspaceSentinelResult = {
|
|
path: string;
|
|
token: string | null;
|
|
result: "written" | "matched" | "missing" | "mismatch" | "skipped";
|
|
};
|
|
|
|
type DaytonaSshAccess = {
|
|
token?: string | null;
|
|
command?: string | null;
|
|
sshCommand?: string | null;
|
|
expiresAt?: string | null;
|
|
};
|
|
|
|
type DaytonaInteractiveSandbox = Sandbox & {
|
|
createSshAccess?: (expiresInMinutes?: number) => Promise<DaytonaSshAccess>;
|
|
_experimental_createSnapshot?: (name: string, timeout?: number) => Promise<void>;
|
|
};
|
|
|
|
type DaytonaSnapshotService = {
|
|
get?: (name: string) => Promise<unknown>;
|
|
delete?: (snapshot: unknown) => Promise<void>;
|
|
};
|
|
|
|
const WORKSPACE_SENTINEL_RELATIVE_PATH = ".paperclip-runtime/reusable-sandbox-lease.json";
|
|
|
|
// Quota-safety defaults (minutes). Daytona counts *stopped* sandboxes against
|
|
// the storage quota; only *archived* sandboxes move to cold object storage and
|
|
// stop counting. Without these, stopped/leaked sandboxes accumulate until the
|
|
// org quota fills. We apply sane defaults so every sandbox eventually leaves the
|
|
// quota on its own even when our own cleanup fails or never runs (crashed runs,
|
|
// failed lease destroys, orphaned probes). All three stay overridable per
|
|
// environment; an explicit 0/-1 in config is preserved.
|
|
//
|
|
// - autoStop: stop idle *running* sandboxes (frees CPU/RAM, starts the archive clock).
|
|
// - autoArchive: archive *stopped* sandboxes so they leave the disk quota.
|
|
// - autoDelete: backstop reaper for sandboxes nobody resumes.
|
|
const DEFAULT_AUTO_STOP_INTERVAL_MINUTES = 15;
|
|
const DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES = 60;
|
|
const DEFAULT_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60; // 7 days
|
|
|
|
// Sandboxes released with `archiveOnRelease` (test/probe runs) are archived so
|
|
// operators can inspect them from the Daytona dashboard, then expired by
|
|
// Daytona itself after this interval (counted from the stop that precedes the
|
|
// archive) so debugging copies don't accumulate.
|
|
const ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES = 60;
|
|
|
|
// Fail-fast cap for git network operations (push, fetch, pull, ls-remote, etc.)
|
|
// so a stalled remote or missing credential never consumes the full 900 s adapter
|
|
// RPC ceiling; callers always see an actionable error within this window.
|
|
const GIT_NETWORK_TIMEOUT_MS = 120_000;
|
|
|
|
// Per-call bound on the provider liveness read (`sandbox.refreshData()`). The
|
|
// Daytona SDK gives this metadata read no timeout, so a silently unresponsive
|
|
// sandbox connection leaves it pending with no error. The plugin then stalls
|
|
// until the outer host-to-worker RPC backstop fires, which is a general ceiling,
|
|
// not a fast, specific detector. This bound turns that silent hang into a fast,
|
|
// clear error. It is configurable through `livenessTimeoutMs`; a value of 0 or
|
|
// less disables the extra bound.
|
|
const DEFAULT_LIVENESS_TIMEOUT_MS = 30_000;
|
|
|
|
// Extra margin added to the SDK start/recover timeout when the plugin wraps
|
|
// those lifecycle calls in its own per-call bound. The SDK call already carries
|
|
// a `timeoutSeconds` deadline; the wrapper is a backstop for a connection-level
|
|
// hang that the SDK deadline can miss. The margin lets the SDK deadline fire
|
|
// first on a normal slow start, so the wrapper only fires on a true hang.
|
|
const LIVENESS_START_TIMEOUT_MARGIN_MS = 5_000;
|
|
|
|
// Noninteractive git credential defaults injected into every Daytona one-shot
|
|
// command so that git operations never stall waiting for a terminal prompt.
|
|
// Callers can override any of these via the env parameter.
|
|
const NONINTERACTIVE_GIT_ENV: Record<string, string> = {
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
GCM_INTERACTIVE: "Never",
|
|
GIT_ASKPASS: "echo",
|
|
SSH_ASKPASS: "echo",
|
|
SSH_ASKPASS_REQUIRE: "force",
|
|
};
|
|
const DEFAULT_SSH_ACCESS_MINUTES = 60;
|
|
const DAYTONA_SSH_GATEWAY_HOST = "ssh.app.daytona.io";
|
|
|
|
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);
|
|
const livenessTimeoutMs = Number(raw.livenessTimeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
|
|
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,
|
|
livenessTimeoutMs: Number.isFinite(livenessTimeoutMs) ? Math.trunc(livenessTimeoutMs) : DEFAULT_LIVENESS_TIMEOUT_MS,
|
|
cpu: parseOptionalNumber(raw.cpu),
|
|
memory: parseOptionalNumber(raw.memory),
|
|
disk: parseOptionalNumber(raw.disk),
|
|
gpu: parseOptionalNumber(raw.gpu),
|
|
autoStopInterval: parseOptionalInteger(raw.autoStopInterval) ?? DEFAULT_AUTO_STOP_INTERVAL_MINUTES,
|
|
autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval) ?? DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES,
|
|
autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval) ?? DEFAULT_AUTO_DELETE_INTERVAL_MINUTES,
|
|
reuseLease: raw.reuseLease === true,
|
|
archiveOnRelease: raw.archiveOnRelease === 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 hasResourceRequest(config: DaytonaDriverConfig): boolean {
|
|
return config.cpu != null || config.memory != null || config.disk != null || config.gpu != null;
|
|
}
|
|
|
|
function validateResourceRequest(config: DaytonaDriverConfig): string | null {
|
|
if (!hasResourceRequest(config) || config.image) return null;
|
|
return "Daytona resource settings require image-backed sandbox creation; snapshot/default sandbox creation cannot override CPU, memory, disk, or GPU.";
|
|
}
|
|
|
|
function validateRuntimeResourceRequest(config: DaytonaDriverConfig): string | null {
|
|
// A snapshot bakes in its own resource allocation, so resources are dropped at
|
|
// create time (see buildCreateParams) rather than failing the run when a custom
|
|
// image snapshot is layered over a base config that carries CPU/memory/disk/GPU.
|
|
if (!hasResourceRequest(config) || config.image || config.snapshot) return null;
|
|
return "Daytona resource settings require image-backed sandbox creation; default sandbox creation cannot override CPU, memory, disk, or GPU.";
|
|
}
|
|
|
|
function buildSandboxLabels(input: {
|
|
companyId: string;
|
|
environmentId: string;
|
|
runId?: string;
|
|
setupSessionId?: string;
|
|
purpose?: 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 } : {}),
|
|
...(input.setupSessionId ? { "paperclip-setup-session-id": input.setupSessionId } : {}),
|
|
...(input.purpose ? { "paperclip-purpose": input.purpose } : {}),
|
|
};
|
|
}
|
|
|
|
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 isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function stableStringify(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
}
|
|
if (isRecord(value)) {
|
|
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value) ?? "null";
|
|
}
|
|
|
|
function isValidUrl(value: string): boolean {
|
|
try {
|
|
new URL(value);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// A per-call liveness bound elapsed before the wrapped provider call returned.
|
|
// The message names the operation and the bound so an operator sees at once
|
|
// that the sandbox connection is unresponsive, not that the operation is slow.
|
|
class SandboxLivenessTimeoutError extends Error {
|
|
constructor(operation: string, timeoutMs: number) {
|
|
super(
|
|
`Daytona sandbox liveness call "${operation}" did not respond within ${timeoutMs} ms; `
|
|
+ "the sandbox connection is unresponsive.",
|
|
);
|
|
this.name = "SandboxLivenessTimeoutError";
|
|
}
|
|
}
|
|
|
|
// Race a provider call against a per-call deadline. A value of 0 or less turns
|
|
// the bound off and runs the call unwrapped. The timer is always cleared, so a
|
|
// call that resolves before the deadline leaks no pending timer. A call that
|
|
// never resolves stays pending after the deadline rejects, but it holds no
|
|
// timer and produces no unhandled rejection.
|
|
async function withLivenessTimeout<T>(
|
|
operation: string,
|
|
timeoutMs: number,
|
|
run: () => Promise<T>,
|
|
): Promise<T> {
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
return run();
|
|
}
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const deadline = new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new SandboxLivenessTimeoutError(operation, timeoutMs)), timeoutMs);
|
|
});
|
|
try {
|
|
return await Promise.race([run(), deadline]);
|
|
} finally {
|
|
if (timer !== undefined) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function ensureSandboxStarted(sandbox: Sandbox, timeoutSeconds: number): Promise<void> {
|
|
if (sandbox.state === "started") return;
|
|
// Bound the lifecycle call just past its own SDK deadline. A normal slow start
|
|
// finishes within `timeoutSeconds`; only a connection-level hang the SDK
|
|
// deadline misses reaches this wrapper bound.
|
|
const startBoundMs = timeoutSeconds * 1_000 + LIVENESS_START_TIMEOUT_MARGIN_MS;
|
|
if (sandbox.state === "error") {
|
|
if (sandbox.recoverable) {
|
|
await withLivenessTimeout("sandbox.recover", startBoundMs, () => sandbox.recover(timeoutSeconds));
|
|
return;
|
|
}
|
|
throw new Error(`Daytona sandbox ${sandbox.id} is in an unrecoverable error state: ${sandbox.errorReason ?? "unknown error"}`);
|
|
}
|
|
await withLivenessTimeout("sandbox.start", startBoundMs, () => 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 parseProbeInteger(value: string | undefined | null): number | null {
|
|
const trimmed = value?.trim() ?? "";
|
|
if (!/^\d+$/.test(trimmed)) {
|
|
return null;
|
|
}
|
|
const parsed = Number.parseInt(trimmed, 10);
|
|
return Number.isInteger(parsed) ? parsed : null;
|
|
}
|
|
|
|
function workspaceSentinelToken(input: {
|
|
params: Pick<PluginEnvironmentAcquireLeaseParams, "companyId" | "environmentId" | "agentId" | "executionWorkspaceId" | "adapterType">;
|
|
config: DaytonaDriverConfig;
|
|
}): string | null {
|
|
if (!input.config.reuseLease || !input.params.agentId || !input.params.executionWorkspaceId) {
|
|
return null;
|
|
}
|
|
return createHash("sha256")
|
|
.update(stableStringify({
|
|
provider: "daytona",
|
|
companyId: input.params.companyId,
|
|
environmentId: input.params.environmentId,
|
|
agentId: input.params.agentId,
|
|
executionWorkspaceId: input.params.executionWorkspaceId,
|
|
adapterType: input.params.adapterType ?? null,
|
|
image: input.config.image,
|
|
snapshot: input.config.snapshot,
|
|
target: input.config.target,
|
|
// Include resource-shaping inputs so changing the requested allocation
|
|
// expires old reusable leases and forces a fresh sandbox instead of
|
|
// reusing a previously provisioned (e.g. one-CPU) sandbox.
|
|
cpu: input.config.cpu,
|
|
memory: input.config.memory,
|
|
disk: input.config.disk,
|
|
gpu: input.config.gpu,
|
|
}))
|
|
.digest("hex");
|
|
}
|
|
|
|
function workspaceSentinelPath(remoteCwd: string): string {
|
|
return path.posix.join(remoteCwd, WORKSPACE_SENTINEL_RELATIVE_PATH);
|
|
}
|
|
|
|
async function writeWorkspaceSentinel(input: {
|
|
sandbox: Sandbox;
|
|
remoteCwd: string;
|
|
params: PluginEnvironmentAcquireLeaseParams;
|
|
config: DaytonaDriverConfig;
|
|
timeoutSeconds: number;
|
|
}): Promise<WorkspaceSentinelResult> {
|
|
const sentinelPath = workspaceSentinelPath(input.remoteCwd);
|
|
const token = workspaceSentinelToken({ params: input.params, config: input.config });
|
|
if (!token) {
|
|
return { path: sentinelPath, token: null, result: "skipped" };
|
|
}
|
|
await input.sandbox.fs.createFolder(path.posix.dirname(sentinelPath), "755");
|
|
await input.sandbox.fs.uploadFile(
|
|
Buffer.from(JSON.stringify({
|
|
version: 1,
|
|
token,
|
|
companyId: input.params.companyId,
|
|
environmentId: input.params.environmentId,
|
|
agentId: input.params.agentId,
|
|
executionWorkspaceId: input.params.executionWorkspaceId,
|
|
adapterType: input.params.adapterType ?? null,
|
|
provider: "daytona",
|
|
writtenAt: new Date().toISOString(),
|
|
}, null, 2), "utf8"),
|
|
sentinelPath,
|
|
input.timeoutSeconds,
|
|
);
|
|
return { path: sentinelPath, token, result: "written" };
|
|
}
|
|
|
|
async function verifyWorkspaceSentinel(input: {
|
|
sandbox: Sandbox;
|
|
remoteCwd: string;
|
|
leaseMetadata?: Record<string, unknown>;
|
|
timeoutSeconds: number;
|
|
}): Promise<WorkspaceSentinelResult> {
|
|
const metadataSentinel = isRecord(input.leaseMetadata?.workspaceSentinel)
|
|
? input.leaseMetadata.workspaceSentinel
|
|
: null;
|
|
const sentinelPath = typeof metadataSentinel?.path === "string"
|
|
? metadataSentinel.path
|
|
: workspaceSentinelPath(input.remoteCwd);
|
|
const expectedToken = typeof metadataSentinel?.token === "string" ? metadataSentinel.token : null;
|
|
if (!expectedToken) {
|
|
return { path: sentinelPath, token: null, result: "missing" };
|
|
}
|
|
|
|
const result = await input.sandbox.process.executeCommand(
|
|
`cat ${shellQuote(sentinelPath)}`,
|
|
undefined,
|
|
undefined,
|
|
input.timeoutSeconds,
|
|
);
|
|
if (result.exitCode !== 0) {
|
|
return { path: sentinelPath, token: expectedToken, result: "missing" };
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(result.result ?? result.artifacts?.stdout ?? "") as unknown;
|
|
const actualToken = isRecord(parsed) && typeof parsed.token === "string" ? parsed.token : null;
|
|
return {
|
|
path: sentinelPath,
|
|
token: expectedToken,
|
|
result: actualToken === expectedToken ? "matched" : "mismatch",
|
|
};
|
|
} catch {
|
|
return { path: sentinelPath, token: expectedToken, result: "mismatch" };
|
|
}
|
|
}
|
|
|
|
function leaseMetadata(input: {
|
|
config: DaytonaDriverConfig;
|
|
sandbox: Sandbox;
|
|
shellCommand: "bash" | "sh";
|
|
remoteCwd: string;
|
|
resumedLease: boolean;
|
|
workspaceSentinel?: WorkspaceSentinelResult;
|
|
}) {
|
|
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,
|
|
// Persisted so the release path (which rebuilds config from lease
|
|
// metadata) still knows to archive instead of delete.
|
|
...(input.config.archiveOnRelease ? { archiveOnRelease: true } : {}),
|
|
remoteCwd: input.remoteCwd,
|
|
resumedLease: input.resumedLease,
|
|
// Record the resources Paperclip attempted to request so future diagnosis
|
|
// can compare requested allocation against what Daytona provisioned.
|
|
...(input.config.cpu != null ? { cpu: input.config.cpu } : {}),
|
|
...(input.config.memory != null ? { memory: input.config.memory } : {}),
|
|
...(input.config.disk != null ? { disk: input.config.disk } : {}),
|
|
...(input.config.gpu != null ? { gpu: input.config.gpu } : {}),
|
|
...(input.workspaceSentinel ? { workspaceSentinel: input.workspaceSentinel } : {}),
|
|
};
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
function resolveConnectionExpiresInMinutes(value: number | null | undefined): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_SSH_ACCESS_MINUTES;
|
|
return Math.min(24 * 60, Math.max(1, Math.trunc(value)));
|
|
}
|
|
|
|
function expiresAtForMinutes(minutes: number): string {
|
|
return new Date(Date.now() + minutes * 60_000).toISOString();
|
|
}
|
|
|
|
// Configure a provider-side time-to-live so Daytona destroys the sandbox at or
|
|
// before the caller-requested deadline, even after a Paperclip crash or outage.
|
|
// `setTtl` counts wall-clock time regardless of the sandbox state, so the destroy
|
|
// happens even when the sandbox is stopped, paused, or archived. The function
|
|
// returns the real provider destroy time (`autoDestroyAt`) as evidence of the
|
|
// provider-side bound. It returns null when the caller sets no deadline, when the
|
|
// deadline is invalid, or when the deadline is less than one minute away (Daytona
|
|
// TTL granularity is one minute, so a nearer deadline maps to no valid TTL). The
|
|
// server then fails closed on a null expiry and releases the lease.
|
|
async function configureSandboxExpiry(input: {
|
|
sandbox: Sandbox;
|
|
requestedExpiresAt: string | null | undefined;
|
|
nowMs: number;
|
|
}): Promise<string | null> {
|
|
const requestedMs = input.requestedExpiresAt ? Date.parse(input.requestedExpiresAt) : Number.NaN;
|
|
if (!Number.isFinite(requestedMs)) return null;
|
|
// Round DOWN so the provider destroy time never lands after the deadline.
|
|
const ttlMinutes = Math.floor((requestedMs - input.nowMs) / 60_000);
|
|
if (ttlMinutes < 1) return null;
|
|
await input.sandbox.setTtl(ttlMinutes);
|
|
await input.sandbox.refreshData();
|
|
const autoDestroyAt = input.sandbox.autoDestroyAt;
|
|
return typeof autoDestroyAt === "string" && autoDestroyAt.trim().length > 0
|
|
? autoDestroyAt.trim()
|
|
: null;
|
|
}
|
|
|
|
function sanitizeSnapshotName(value: string | null | undefined, fallback: string): string {
|
|
const cleaned = (value ?? fallback)
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 96);
|
|
return cleaned || fallback;
|
|
}
|
|
|
|
function withSetupSourceTemplate(
|
|
config: DaytonaDriverConfig,
|
|
params: Pick<PluginEnvironmentStartInteractiveSetupParams, "sourceTemplateRef" | "sourceTemplateKind">,
|
|
): DaytonaDriverConfig {
|
|
if (!params.sourceTemplateRef) return config;
|
|
const sourceKind = params.sourceTemplateKind ?? "snapshot";
|
|
if (sourceKind === "image") {
|
|
return {
|
|
...config,
|
|
image: params.sourceTemplateRef,
|
|
snapshot: null,
|
|
};
|
|
}
|
|
if (sourceKind !== "snapshot") {
|
|
throw new Error(`Daytona interactive setup can start from image or snapshot templates only, not ${sourceKind}.`);
|
|
}
|
|
return {
|
|
...config,
|
|
snapshot: params.sourceTemplateRef,
|
|
image: null,
|
|
};
|
|
}
|
|
|
|
async function createSshConnection(
|
|
sandbox: Sandbox,
|
|
expiresInMinutes: number,
|
|
): Promise<Pick<PluginEnvironmentInteractiveSetupSession, "connectionSummary" | "connectionPayload">> {
|
|
const createSshAccess = (sandbox as DaytonaInteractiveSandbox).createSshAccess;
|
|
if (typeof createSshAccess !== "function") {
|
|
throw new Error(
|
|
"Daytona interactive setup requires @daytonaio/sdk Sandbox.createSshAccess support.",
|
|
);
|
|
}
|
|
|
|
const fallbackExpiresAt = expiresAtForMinutes(expiresInMinutes);
|
|
const access = await createSshAccess.call(sandbox, expiresInMinutes);
|
|
const token = typeof access.token === "string" && access.token.trim().length > 0
|
|
? access.token.trim()
|
|
: null;
|
|
const commandFromAccess =
|
|
typeof access.command === "string" && access.command.trim().length > 0
|
|
? access.command.trim()
|
|
: typeof access.sshCommand === "string" && access.sshCommand.trim().length > 0
|
|
? access.sshCommand.trim()
|
|
: null;
|
|
const command = commandFromAccess ?? (token ? `ssh ${token}@${DAYTONA_SSH_GATEWAY_HOST}` : null);
|
|
if (!command) {
|
|
throw new Error("Daytona SSH access did not return a token or SSH command.");
|
|
}
|
|
const expiresAt = typeof access.expiresAt === "string" && access.expiresAt.trim().length > 0
|
|
? access.expiresAt.trim()
|
|
: fallbackExpiresAt;
|
|
|
|
return {
|
|
connectionSummary: {
|
|
type: "ssh",
|
|
username: "token",
|
|
hostRedacted: true,
|
|
portRedacted: true,
|
|
commandRedacted: true,
|
|
expiresAt,
|
|
metadata: {
|
|
provider: "daytona",
|
|
expiresInMinutes,
|
|
},
|
|
},
|
|
connectionPayload: {
|
|
type: "ssh",
|
|
command,
|
|
token,
|
|
expiresAt,
|
|
metadata: {
|
|
provider: "daytona",
|
|
sensitive: true,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function interactiveSetupMetadata(input: {
|
|
config: DaytonaDriverConfig;
|
|
sandbox: Sandbox;
|
|
shellCommand: "bash" | "sh";
|
|
remoteCwd: string;
|
|
sourceTemplateRef?: string | null;
|
|
}) {
|
|
return {
|
|
provider: "daytona",
|
|
sandboxId: input.sandbox.id,
|
|
sandboxState: input.sandbox.state ?? null,
|
|
shellCommand: input.shellCommand,
|
|
imageConfigured: Boolean(input.config.image),
|
|
snapshotConfigured: Boolean(input.config.snapshot),
|
|
sourceTemplateRefRedacted: Boolean(input.sourceTemplateRef),
|
|
target: input.sandbox.target,
|
|
timeoutMs: input.config.timeoutMs,
|
|
remoteCwd: input.remoteCwd,
|
|
connectionRedacted: true,
|
|
};
|
|
}
|
|
|
|
function isValidShellEnvKey(value: string): boolean {
|
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
}
|
|
|
|
const GIT_NETWORK_SUBCOMMANDS = new Set(["push", "fetch", "pull", "ls-remote", "clone"]);
|
|
|
|
function isGitNetworkCommand(command: string, args: string[]): boolean {
|
|
if (path.basename(command) !== "git") return false;
|
|
// Find the first positional arg (the git subcommand), skipping flags and their values.
|
|
let i = 0;
|
|
while (i < args.length) {
|
|
const arg = args[i];
|
|
if (arg === "-C" || arg === "-c" || arg === "--git-dir" || arg === "--work-tree") {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-")) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (GIT_NETWORK_SUBCOMMANDS.has(arg)) return true;
|
|
if (arg === "remote") {
|
|
const next = args.slice(i + 1).find(a => !a.startsWith("-"));
|
|
return next === "update";
|
|
}
|
|
if (arg === "submodule") {
|
|
const next = args.slice(i + 1).find(a => !a.startsWith("-"));
|
|
return next === "update";
|
|
}
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Build the one-shot exec command. Daytona's `executeCommand` runs the script
|
|
// in a non-login shell, so it does not source `/etc/profile` on its own. The
|
|
// Daytona reference image puts `node`, `claude`, and the other CLIs on the PATH
|
|
// through `/etc/profile.d/00-restore-env.sh`, which only `/etc/profile` sources.
|
|
// So the wrapper sources the login profiles itself; a non-login shell is then
|
|
// enough to resolve the CLIs. The wrapper no longer sources `nvm.sh`; the
|
|
// sandbox image supplies `node` on the PATH. See the sandbox runtime
|
|
// requirements document.
|
|
function buildLoginShellScript(input: {
|
|
command: string;
|
|
args: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
stdinPath?: string;
|
|
}): string {
|
|
const callerEnv = input.env ?? {};
|
|
for (const key of Object.keys(callerEnv)) {
|
|
if (!isValidShellEnvKey(key)) {
|
|
throw new Error(`Invalid sandbox environment variable key: ${key}`);
|
|
}
|
|
}
|
|
// Caller env takes priority over noninteractive git credential defaults
|
|
const env = { ...NONINTERACTIVE_GIT_ENV, ...callerEnv };
|
|
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.
|
|
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',
|
|
];
|
|
if (input.cwd) {
|
|
lines.push(`cd ${shellQuote(input.cwd)}`);
|
|
}
|
|
lines.push(finalLine);
|
|
return lines.join(" && ");
|
|
}
|
|
|
|
// The workspace remote dir is the confinement root for native file sync. It is
|
|
// recorded on the lease metadata at acquire/resume time; require it so a sync can
|
|
// never run without a concrete root to confine every sandbox path against.
|
|
function resolveSyncRemoteDir(lease: { metadata?: Record<string, unknown> | null }): string {
|
|
const remoteCwd = lease.metadata?.remoteCwd;
|
|
if (typeof remoteCwd === "string" && remoteCwd.trim().length > 0) {
|
|
return remoteCwd.trim();
|
|
}
|
|
throw new Error("Daytona file sync requires a workspace remote dir on the lease metadata.");
|
|
}
|
|
|
|
async function createSandbox(
|
|
params: PluginEnvironmentAcquireLeaseParams | PluginEnvironmentProbeParams | PluginEnvironmentStartInteractiveSetupParams,
|
|
config: DaytonaDriverConfig,
|
|
options: { purpose?: string } = {},
|
|
): Promise<Sandbox> {
|
|
const resourceRequestError = validateRuntimeResourceRequest(config);
|
|
if (resourceRequestError) {
|
|
throw new Error(resourceRequestError);
|
|
}
|
|
const client = createDaytonaClient(config);
|
|
const createParams = buildCreateParams(config, buildSandboxLabels({
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
runId: "runId" in params ? params.runId : undefined,
|
|
setupSessionId: "sessionId" in params ? params.sessionId : undefined,
|
|
purpose: options.purpose,
|
|
reuseLease: config.reuseLease,
|
|
}));
|
|
const sandbox = await client.create(createParams, {
|
|
timeout: toTimeoutSeconds(config.timeoutMs),
|
|
});
|
|
return sandbox;
|
|
}
|
|
|
|
// ─── Per-lease started-sandbox handle cache ──────────────────────────────────
|
|
// Memoize the started Daytona `Sandbox` handle so repeated exec/sync/resume/
|
|
// teardown calls on one lease skip the per-call `client.get(sandboxId)` REST
|
|
// re-fetch (measured ~4,938 ms on `stage.sync`) and the client construction it
|
|
// implies. The cache is process-memory only — no handle, API key, or credential
|
|
// is ever persisted or logged (Stage-1 security review C6).
|
|
//
|
|
// Isolation is the whole game here: the cached object is an authenticated
|
|
// compute handle, so a mis-keyed or un-evicted entry could run one lease/tenant's
|
|
// commands inside another's sandbox. The guarantees below map 1:1 to the Stage-1
|
|
// required-fix conditions:
|
|
// C1 Key by a NON-SECRET composite scope, never the bare providerLeaseId:
|
|
// {driverKey, companyId, environmentId, providerLeaseId, account}. The
|
|
// account discriminator is a hash of the resolved endpoint + credentials
|
|
// so two environments pointing at different Daytona accounts (or a rotated
|
|
// key) never collide — without storing the secret in the key.
|
|
// C2 Every read (cache hit AND resolved single-flight populate) asserts the
|
|
// handle's `sandbox.id === providerLeaseId`; a mismatch evicts and throws
|
|
// (fail closed) rather than serving the wrong sandbox.
|
|
// C4 Callers evict at every teardown hook. Populate rejections (NotFound,
|
|
// network, id mismatch) are never cached — they drop from the map so the
|
|
// next call re-fetches.
|
|
// C5 In-flight populate promises live under the composite key only; there is
|
|
// no fallback lookup by bare providerLeaseId, so lease A's in-flight
|
|
// promise can never be awaited for lease B.
|
|
type SandboxScope = {
|
|
driverKey: string;
|
|
companyId: string;
|
|
environmentId: string;
|
|
providerLeaseId: string;
|
|
config: DaytonaDriverConfig;
|
|
};
|
|
|
|
// Non-secret provider/account fingerprint. Uses the *resolved* key (config or
|
|
// DAYTONA_API_KEY env fallback) so an env-provided credential is still scoped,
|
|
// but only its sha256 digest — never the key itself — enters the cache key (C1/C6).
|
|
function sandboxAccountDiscriminator(config: DaytonaDriverConfig): string {
|
|
const resolvedApiKey = config.apiKey ?? process.env.DAYTONA_API_KEY?.trim() ?? null;
|
|
return createHash("sha256")
|
|
.update(stableStringify({
|
|
apiUrl: config.apiUrl,
|
|
target: config.target,
|
|
apiKey: resolvedApiKey,
|
|
}))
|
|
.digest("hex");
|
|
}
|
|
|
|
function sandboxHandleCacheKey(scope: SandboxScope): string {
|
|
return stableStringify({
|
|
driverKey: scope.driverKey,
|
|
companyId: scope.companyId,
|
|
environmentId: scope.environmentId,
|
|
providerLeaseId: scope.providerLeaseId,
|
|
account: sandboxAccountDiscriminator(scope.config),
|
|
});
|
|
}
|
|
|
|
function assertHandleMatchesLease(sandbox: Sandbox, providerLeaseId: string): void {
|
|
// C2: a handle must never stand in for a different sandbox than the lease
|
|
// asked for. Belt-and-suspenders against a provider that returns a renamed or
|
|
// substituted sandbox, and against any future key collision.
|
|
if (sandbox.id !== providerLeaseId) {
|
|
throw new Error(
|
|
`Daytona sandbox handle mismatch: handle ${sandbox.id} does not belong to lease ${providerLeaseId}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// A cached `Sandbox` carries the provider state captured when it was last
|
|
// fetched/refreshed. Daytona auto-stops an idle sandbox after `autoStopInterval`
|
|
// minutes, at which point that snapshot ("started") no longer matches reality
|
|
// and `ensureSandboxStarted` would wrongly skip the restart, sending every
|
|
// subsequent exec/sync at a stopped sandbox. Before reusing a handle that has
|
|
// gone untouched for this fraction of the auto-stop interval we re-read the live
|
|
// state so the restart decision is made against the truth. Reusing a handle for
|
|
// an operation resets Daytona's idle clock, so an actively-used lease stays well
|
|
// inside the window and never pays the refresh — only a lease resumed after an
|
|
// idle gap does.
|
|
const STALE_HANDLE_REFRESH_SAFETY_FRACTION = 0.5;
|
|
|
|
function staleHandleRefreshThresholdMs(autoStopIntervalMinutes: number | null): number | null {
|
|
// Auto-stop disabled (0 / null): the provider never stops the sandbox out from
|
|
// under a live handle, so the started snapshot stays valid until we evict it
|
|
// and no refresh is warranted.
|
|
if (autoStopIntervalMinutes == null || autoStopIntervalMinutes <= 0) return null;
|
|
return Math.floor(autoStopIntervalMinutes * 60_000 * STALE_HANDLE_REFRESH_SAFETY_FRACTION);
|
|
}
|
|
|
|
type SandboxHandleCacheEntry = {
|
|
sandbox: Promise<Sandbox>;
|
|
// Last time we know the live state was accurate: set when the handle is
|
|
// fetched/refreshed and on every reuse (an operation follows, resetting the
|
|
// provider idle clock).
|
|
verifiedAtMs: number;
|
|
};
|
|
|
|
type SandboxLookupOptions = {
|
|
bypassTeardownGate?: boolean;
|
|
// Report the cache decision at the handle lookup. `true` means the warm
|
|
// handle cache served the handle; `false` means the lookup called
|
|
// `client.get`. The caller uses this to set the explicit exec `cache_hit`
|
|
// flag, instead of the old `providerGetMs == 0` proxy.
|
|
onCacheDecision?: (cacheHit: boolean) => void;
|
|
};
|
|
|
|
type SandboxHandleTeardownGate = {
|
|
promise: Promise<void>;
|
|
release: () => void;
|
|
refCount: number;
|
|
};
|
|
|
|
const sandboxHandleTeardownGates = (() => {
|
|
const gates = new Map<string, SandboxHandleTeardownGate>();
|
|
|
|
function begin(scope: SandboxScope): SandboxHandleTeardownGate {
|
|
const key = sandboxHandleCacheKey(scope);
|
|
const existing = gates.get(key);
|
|
if (existing) {
|
|
existing.refCount += 1;
|
|
return existing;
|
|
}
|
|
let release!: () => void;
|
|
const gate: SandboxHandleTeardownGate = {
|
|
promise: new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
}),
|
|
release: () => release(),
|
|
refCount: 1,
|
|
};
|
|
gates.set(key, gate);
|
|
return gate;
|
|
}
|
|
|
|
function current(scope: SandboxScope): SandboxHandleTeardownGate | null {
|
|
return gates.get(sandboxHandleCacheKey(scope)) ?? null;
|
|
}
|
|
|
|
function end(scope: SandboxScope, gate: SandboxHandleTeardownGate): void {
|
|
const key = sandboxHandleCacheKey(scope);
|
|
gate.refCount -= 1;
|
|
if (gate.refCount > 0) return;
|
|
if (gates.get(key) === gate) {
|
|
gates.delete(key);
|
|
}
|
|
gate.release();
|
|
}
|
|
|
|
function reset(): void {
|
|
gates.clear();
|
|
}
|
|
|
|
return { begin, current, end, reset };
|
|
})();
|
|
|
|
type SandboxHandleActivityGate = {
|
|
promise: Promise<void>;
|
|
release: () => void;
|
|
refCount: number;
|
|
};
|
|
|
|
const sandboxHandleActivityGates = (() => {
|
|
const gates = new Map<string, SandboxHandleActivityGate>();
|
|
|
|
async function begin(scope: SandboxScope): Promise<SandboxHandleActivityGate> {
|
|
const key = sandboxHandleCacheKey(scope);
|
|
const existing = gates.get(key);
|
|
if (existing) {
|
|
existing.refCount += 1;
|
|
return existing;
|
|
}
|
|
let release!: () => void;
|
|
const gate: SandboxHandleActivityGate = {
|
|
promise: new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
}),
|
|
release: () => release(),
|
|
refCount: 1,
|
|
};
|
|
gates.set(key, gate);
|
|
return gate;
|
|
}
|
|
|
|
async function waitForIdle(scope: SandboxScope): Promise<void> {
|
|
const gate = gates.get(sandboxHandleCacheKey(scope));
|
|
if (!gate) return;
|
|
await gate.promise;
|
|
}
|
|
|
|
function end(scope: SandboxScope, gate: SandboxHandleActivityGate): void {
|
|
const key = sandboxHandleCacheKey(scope);
|
|
gate.refCount -= 1;
|
|
if (gate.refCount > 0) return;
|
|
if (gates.get(key) === gate) {
|
|
gates.delete(key);
|
|
}
|
|
gate.release();
|
|
}
|
|
|
|
function reset(): void {
|
|
gates.clear();
|
|
}
|
|
|
|
return { begin, waitForIdle, end, reset };
|
|
})();
|
|
|
|
type SandboxLeaseAdmissionOptions = {
|
|
allowClosed?: boolean;
|
|
};
|
|
|
|
const sandboxHandleLeaseAdmissionStates = (() => {
|
|
const states = new Map<string, boolean>();
|
|
|
|
function key(scope: SandboxScope): string {
|
|
return sandboxHandleCacheKey(scope);
|
|
}
|
|
|
|
function open(scope: SandboxScope): void {
|
|
states.set(key(scope), false);
|
|
}
|
|
|
|
function close(scope: SandboxScope): void {
|
|
states.set(key(scope), true);
|
|
}
|
|
|
|
function isClosed(scope: SandboxScope): boolean {
|
|
return states.get(key(scope)) === true;
|
|
}
|
|
|
|
function reset(): void {
|
|
states.clear();
|
|
}
|
|
|
|
return { open, close, isClosed, reset };
|
|
})();
|
|
|
|
async function withSandboxActivityGate<T>(
|
|
scope: SandboxScope,
|
|
fn: () => Promise<T>,
|
|
options: SandboxLeaseAdmissionOptions = {},
|
|
): Promise<T> {
|
|
while (true) {
|
|
if (!options.allowClosed && sandboxHandleLeaseAdmissionStates.isClosed(scope)) {
|
|
throw new Error(`Daytona sandbox lease ${scope.providerLeaseId} is no longer active.`);
|
|
}
|
|
|
|
const teardownGate = sandboxHandleTeardownGates.current(scope);
|
|
if (teardownGate) {
|
|
await teardownGate.promise;
|
|
if (!options.allowClosed && sandboxHandleLeaseAdmissionStates.isClosed(scope)) {
|
|
throw new Error(`Daytona sandbox lease ${scope.providerLeaseId} is no longer active.`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const activityGate = await sandboxHandleActivityGates.begin(scope);
|
|
try {
|
|
// A teardown can still begin between the initial check above and the
|
|
// activity-gate admission. If that happens, back out and wait for the
|
|
// teardown to finish instead of proceeding into a race with cleanup.
|
|
if (sandboxHandleTeardownGates.current(scope)) {
|
|
continue;
|
|
}
|
|
if (!options.allowClosed && sandboxHandleLeaseAdmissionStates.isClosed(scope)) {
|
|
throw new Error(`Daytona sandbox lease ${scope.providerLeaseId} is no longer active.`);
|
|
}
|
|
return await fn();
|
|
} finally {
|
|
sandboxHandleActivityGates.end(scope, activityGate);
|
|
}
|
|
}
|
|
}
|
|
|
|
const sandboxHandleCache = (() => {
|
|
const entries = new Map<string, SandboxHandleCacheEntry>();
|
|
|
|
function markFresh(scope: SandboxScope): void {
|
|
const entry = entries.get(sandboxHandleCacheKey(scope));
|
|
if (entry) {
|
|
entry.verifiedAtMs = handleFreshnessNow();
|
|
}
|
|
}
|
|
|
|
async function get(scope: SandboxScope, options: SandboxLookupOptions = {}): Promise<Sandbox> {
|
|
const key = sandboxHandleCacheKey(scope);
|
|
|
|
const entry = entries.get(key);
|
|
if (entry) {
|
|
// The warm handle cache holds an entry, so this lookup serves the handle
|
|
// without a `client.get` round trip. Report the cache decision now.
|
|
options.onCacheDecision?.(true);
|
|
const sandbox = await entry.sandbox;
|
|
// Re-assert on every hit; evict + fail closed on any mismatch (C2).
|
|
try {
|
|
assertHandleMatchesLease(sandbox, scope.providerLeaseId);
|
|
} catch (error) {
|
|
entries.delete(key);
|
|
throw error;
|
|
}
|
|
// Refresh the live provider state if the handle may have been auto-stopped
|
|
// since we last confirmed it, so the cached `state` snapshot can't hide a
|
|
// provider-initiated stop from `ensureSandboxStarted`. A failed refresh
|
|
// means the handle is no longer trustworthy — evict and fail closed.
|
|
const thresholdMs = staleHandleRefreshThresholdMs(scope.config.autoStopInterval);
|
|
if (thresholdMs != null && handleFreshnessNow() - entry.verifiedAtMs >= thresholdMs) {
|
|
try {
|
|
await withLivenessTimeout("sandbox.refreshData", scope.config.livenessTimeoutMs, () =>
|
|
sandbox.refreshData(),
|
|
);
|
|
} catch (error) {
|
|
entries.delete(key);
|
|
throw error;
|
|
}
|
|
}
|
|
return sandbox;
|
|
}
|
|
// The warm handle cache holds no entry, so this lookup calls `client.get`.
|
|
// Report the cache decision now, before the single-flight populate.
|
|
options.onCacheDecision?.(false);
|
|
// Single-flight: the first miss stores the in-flight promise under the
|
|
// composite key so concurrent misses on the same lease share one `client.get`
|
|
// instead of double-fetching. The promise lives only under this key (C5).
|
|
const populate = (async () => {
|
|
const client = createDaytonaClient(scope.config);
|
|
const sandbox = await client.get(scope.providerLeaseId);
|
|
assertHandleMatchesLease(sandbox, scope.providerLeaseId);
|
|
return sandbox;
|
|
})();
|
|
const populated: SandboxHandleCacheEntry = { sandbox: populate, verifiedAtMs: handleFreshnessNow() };
|
|
entries.set(key, populated);
|
|
try {
|
|
const sandbox = await populate;
|
|
return sandbox;
|
|
} catch (error) {
|
|
// A rejected populate (NotFound, network, id mismatch) must never remain
|
|
// cached (C4/C5). Guard against clobbering a newer entry under the key.
|
|
if (entries.get(key) === populated) {
|
|
entries.delete(key);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Seed the cache with a handle the caller already holds (e.g. the fresh handle
|
|
// from `createSandbox` on a cold acquire), so the next `get` under the same
|
|
// scope reuses it instead of paying a real `client.get`. The seed must land
|
|
// under the exact composite key the reader uses, or the reader misses and the
|
|
// saved round trip is lost. Assert the handle belongs to the lease so a caller
|
|
// that builds a wrong scope fails loudly here instead of caching a foreign
|
|
// handle.
|
|
function seed(scope: SandboxScope, sandbox: Sandbox): void {
|
|
assertHandleMatchesLease(sandbox, scope.providerLeaseId);
|
|
entries.set(sandboxHandleCacheKey(scope), {
|
|
sandbox: Promise.resolve(sandbox),
|
|
verifiedAtMs: handleFreshnessNow(),
|
|
});
|
|
}
|
|
|
|
function clear(scope: SandboxScope): void {
|
|
entries.delete(sandboxHandleCacheKey(scope));
|
|
}
|
|
|
|
function reset(): void {
|
|
entries.clear();
|
|
}
|
|
|
|
// Resolve a cached sandbox by its provider lease id alone. The
|
|
// login pseudo-terminal open carries only the provider lease id, not the full
|
|
// scope, so this scans the cached handles for the one whose `sandbox.id`
|
|
// matches. The lease was cached on acquire in the same worker, so the scan is
|
|
// a hit for a live login lease. It returns null when no cached handle matches,
|
|
// so the caller fails closed.
|
|
async function findByProviderLeaseId(providerLeaseId: string): Promise<Sandbox | null> {
|
|
if (!providerLeaseId) return null;
|
|
for (const entry of entries.values()) {
|
|
let sandbox: Sandbox;
|
|
try {
|
|
sandbox = await entry.sandbox;
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (sandbox.id === providerLeaseId) return sandbox;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return { get, seed, clear, reset, markFresh, findByProviderLeaseId };
|
|
})();
|
|
|
|
// Preview credentials can rotate without a sandbox restart, so they must not
|
|
// define endpoint generation. Daytona's lifecycle revision does: refreshData
|
|
// updates `updatedAt` after stop/start. When Daytona does not expose a
|
|
// lifecycle revision, the in-memory generation remains stable for the worker.
|
|
const runnerIngressGenerationStore = (() => {
|
|
const entries = new Map<
|
|
string,
|
|
{ revision: string | null; generation: string }
|
|
>();
|
|
|
|
function get(sandbox: Sandbox): string {
|
|
const revision = sandbox.updatedAt ?? sandbox.createdAt ?? null;
|
|
const current = entries.get(sandbox.id);
|
|
if (current && current.revision === revision) return current.generation;
|
|
const generation = createHash("sha256")
|
|
.update(`${sandbox.id}\0${revision ?? randomUUID()}`)
|
|
.digest("hex");
|
|
entries.set(sandbox.id, { revision, generation });
|
|
return generation;
|
|
}
|
|
|
|
function reset(): void {
|
|
entries.clear();
|
|
}
|
|
|
|
return { get, reset };
|
|
})();
|
|
|
|
// Advisory writable-set store. It holds, per lease scope, the sandbox
|
|
// directories that a sync operation declared read-write (`access: "rw"`). The
|
|
// store is advisory and best-effort in-memory state: it adds no security (the
|
|
// ephemeral sandbox stays the only boundary). The store is keyed the same way
|
|
// as `sandboxHandleCache`, by `sandboxHandleCacheKey(scope)`.
|
|
//
|
|
// The command path no longer reads this set. The provider dropped the advisory
|
|
// `bwrap` wrapper that once bound these directories read-write for real-time
|
|
// feedback (see `DIRECTORY-CONSTRAINT-FINDINGS.md`). The store still records the
|
|
// read-write set, so a future isolation wrapper for the session can consume it
|
|
// without a new sync change.
|
|
const sandboxHandleWritableDirs = (() => {
|
|
const dirsByKey = new Map<string, Set<string>>();
|
|
|
|
// Record the read-write destination directory of every `access: "rw"`
|
|
// mapping. Skip read-only mappings (`access` absent or `"ro"`). Read-only is
|
|
// the safe default for an advisory signal.
|
|
//
|
|
// A workspace, git-history, or asset mapping uploads a tar archive, so its
|
|
// `targetPath` is the staging archive under the runtime root, not the directory
|
|
// that the post-upload extract command fills. For those mappings the author
|
|
// sets `writablePath` to the final destination directory, so this records the
|
|
// real read-write destination, not the staging parent. When `writablePath` is
|
|
// absent the mapping writes `targetPath` in place, so the parent directory of
|
|
// `targetPath` is the destination.
|
|
function recordWritableTargets(scope: SandboxScope, operations: PluginSyncOperation[]): void {
|
|
const key = sandboxHandleCacheKey(scope);
|
|
for (const operation of operations) {
|
|
for (const mapping of operation.files) {
|
|
if (mapping.access !== "rw") continue;
|
|
let dirs = dirsByKey.get(key);
|
|
if (!dirs) {
|
|
dirs = new Set<string>();
|
|
dirsByKey.set(key, dirs);
|
|
}
|
|
dirs.add(mapping.writablePath ?? path.posix.dirname(mapping.targetPath));
|
|
}
|
|
}
|
|
}
|
|
|
|
function get(scope: SandboxScope): ReadonlySet<string> {
|
|
return dirsByKey.get(sandboxHandleCacheKey(scope)) ?? new Set<string>();
|
|
}
|
|
|
|
function reset(): void {
|
|
dirsByKey.clear();
|
|
}
|
|
|
|
return { recordWritableTargets, get, reset };
|
|
})();
|
|
|
|
// Per-lease Daytona session-id store. It holds, per lease scope, the id of the
|
|
// one persistent session the exec hook opened for that lease. The store is
|
|
// keyed the same way as `sandboxHandleCache`, by `sandboxHandleCacheKey(scope)`.
|
|
// The exec hook creates one session on a cache miss and records its id here. The
|
|
// teardown hooks delete the session and clear the id. A resume clears the id,
|
|
// because a restarted sandbox loses its session shell, so the next exec must
|
|
// open a fresh session. The store is process-memory only; it holds an id string,
|
|
// never a handle, a credential, or a command.
|
|
const sandboxHandleSessionStore = (() => {
|
|
const idByKey = new Map<string, string>();
|
|
// In-flight session creates, keyed the same way as `idByKey`. A create records
|
|
// its promise here for the time it runs, then removes it. The map lets two
|
|
// overlapping first commands for one lease share one create. See `runSingle`.
|
|
const pendingByKey = new Map<string, Promise<string>>();
|
|
|
|
function get(scope: SandboxScope): string | undefined {
|
|
return idByKey.get(sandboxHandleCacheKey(scope));
|
|
}
|
|
|
|
function set(scope: SandboxScope, sessionId: string): void {
|
|
idByKey.set(sandboxHandleCacheKey(scope), sessionId);
|
|
}
|
|
|
|
function clear(scope: SandboxScope): void {
|
|
idByKey.delete(sandboxHandleCacheKey(scope));
|
|
}
|
|
|
|
// Single-flight guard for the first-command session create. Two overlapping
|
|
// first commands for one lease must open at most one live session. The first
|
|
// caller runs `create` and records its in-flight promise; every concurrent
|
|
// caller awaits the same promise instead of a second `create`. The store keeps
|
|
// the promise only while `create` runs, then removes it, so a later command
|
|
// (for example, after a resume clears the id) can open a fresh session. A
|
|
// failed `create` removes the promise too, so the next command retries.
|
|
function runSingle(scope: SandboxScope, create: () => Promise<string>): Promise<string> {
|
|
const key = sandboxHandleCacheKey(scope);
|
|
const inFlight = pendingByKey.get(key);
|
|
if (inFlight) return inFlight;
|
|
const promise = create();
|
|
pendingByKey.set(key, promise);
|
|
const settle = (): void => {
|
|
pendingByKey.delete(key);
|
|
};
|
|
promise.then(settle, settle);
|
|
return promise;
|
|
}
|
|
|
|
function reset(): void {
|
|
idByKey.clear();
|
|
pendingByKey.clear();
|
|
}
|
|
|
|
return { get, set, clear, runSingle, reset };
|
|
})();
|
|
|
|
/**
|
|
* Test seam: clear the process-scoped handle cache between tests so a handle
|
|
* memoized under a reused composite key in one test never leaks into the next.
|
|
* Not used in production.
|
|
*/
|
|
export function __resetDaytonaSandboxHandleCacheForTest(): void {
|
|
sandboxHandleCache.reset();
|
|
sandboxHandleTeardownGates.reset();
|
|
sandboxHandleActivityGates.reset();
|
|
sandboxHandleLeaseAdmissionStates.reset();
|
|
sandboxHandleWritableDirs.reset();
|
|
sandboxHandleSessionStore.reset();
|
|
runnerIngressGenerationStore.reset();
|
|
}
|
|
|
|
/**
|
|
* Test seam: read the advisory writable directories recorded for a sync scope.
|
|
* The caller passes the same `onEnvironmentSyncIn` inputs, so this rebuilds the
|
|
* exact scope key the hook used. Not used in production.
|
|
*/
|
|
export function __getDaytonaWritableDirsForTest(input: {
|
|
driverKey: string;
|
|
companyId: string;
|
|
environmentId: string;
|
|
lease: { providerLeaseId?: string | null };
|
|
config: Record<string, unknown>;
|
|
}): string[] {
|
|
const scope: SandboxScope = {
|
|
driverKey: input.driverKey,
|
|
companyId: input.companyId,
|
|
environmentId: input.environmentId,
|
|
providerLeaseId: input.lease.providerLeaseId ?? "",
|
|
config: parseDriverConfig(input.config),
|
|
};
|
|
return [...sandboxHandleWritableDirs.get(scope)];
|
|
}
|
|
|
|
async function getSandbox(scope: SandboxScope, options: SandboxLookupOptions = {}): Promise<Sandbox> {
|
|
return await sandboxHandleCache.get(scope, options);
|
|
}
|
|
|
|
async function getSandboxOrNull(scope: SandboxScope, options: SandboxLookupOptions = {}): Promise<Sandbox | null> {
|
|
try {
|
|
return await getSandbox(scope, options);
|
|
} catch (error) {
|
|
if (error instanceof DaytonaNotFoundError) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function evictSandboxHandle(scope: SandboxScope): void {
|
|
sandboxHandleCache.clear(scope);
|
|
}
|
|
|
|
// Return the persistent session id for a lease, and open one session on a cache
|
|
// miss. The exec hook calls this once per command. The first call opens the
|
|
// session through `createSession` and records its id; every later call returns
|
|
// the stored id, so one lease runs every command in one persistent shell. The
|
|
// provider never falls back to a one-shot command to open a session.
|
|
//
|
|
// Leak bound: the Daytona SDK exposes NO per-session TTL. `createSession` takes
|
|
// only a session id, and there is no session update or expiry field. Two
|
|
// backstops bound the session against a leak. First, `teardownSession` runs a
|
|
// guaranteed `deleteSession` in every teardown hook's `try/finally`. Second, the
|
|
// sandbox-level `autoStopInterval` (15 minutes idle by default) stops the
|
|
// sandbox and, with it, every session; the `autoArchiveInterval` and
|
|
// `autoDeleteInterval` intervals then reap the sandbox. A session is a shell
|
|
// inside its sandbox and cannot outlive it.
|
|
async function getOrCreateSession(sandbox: Sandbox, scope: SandboxScope): Promise<string> {
|
|
const existing = sandboxHandleSessionStore.get(scope);
|
|
if (existing) return existing;
|
|
// Single-flight the first-command create. Two overlapping first commands for
|
|
// one lease share one create promise, so the lease opens at most one live
|
|
// session. The guard checks and starts the create in one synchronous step, so
|
|
// no second command can slip in between the store read and the create start.
|
|
return sandboxHandleSessionStore.runSingle(scope, async () => {
|
|
const sessionId = `paperclip-${randomUUID()}`;
|
|
// Wrap the session create in a short `session.open` provider span. The span
|
|
// carries no session id and no command text, only the provider family. The
|
|
// host maps the name to `sandbox.daytona.session.open`.
|
|
// `session.open` span: create the one persistent Daytona session for a lease,
|
|
// on the first in-run command — `sandbox.process.createSession`.
|
|
await withProviderSpan({
|
|
name: "session.open",
|
|
run: () => sandbox.process.createSession(sessionId),
|
|
});
|
|
sandboxHandleSessionStore.set(scope, sessionId);
|
|
return sessionId;
|
|
});
|
|
}
|
|
|
|
// Delete the persistent session for a lease and clear its stored id. Each
|
|
// teardown hook calls this inside its `try/finally`, so a failed delete never
|
|
// skips the rest of teardown. A failed delete logs the session id and the error
|
|
// loudly and does not throw past teardown; the sandbox stop or delete that
|
|
// follows removes the session shell anyway, and the sandbox-level
|
|
// `autoStopInterval` / `autoDeleteInterval` / `autoArchiveInterval` backstops
|
|
// bound any residual state (the session API exposes no per-session TTL). The
|
|
// store id is always cleared, so no orphan id survives.
|
|
async function teardownSession(sandbox: Sandbox, scope: SandboxScope): Promise<void> {
|
|
const sessionId = sandboxHandleSessionStore.get(scope);
|
|
if (!sessionId) return;
|
|
try {
|
|
// Wrap the session delete in a short `session.close` provider span. The
|
|
// host maps the name to `sandbox.daytona.session.close`.
|
|
// `session.close` span: delete that persistent session on lease release —
|
|
// `sandbox.process.deleteSession`.
|
|
await withProviderSpan({
|
|
name: "session.close",
|
|
run: () => sandbox.process.deleteSession(sessionId),
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
`Failed to delete Daytona session ${sessionId} during teardown: ${formatErrorMessage(error)}`,
|
|
);
|
|
} finally {
|
|
sandboxHandleSessionStore.clear(scope);
|
|
}
|
|
}
|
|
|
|
// One-shot command execution via Daytona's `process.executeCommand`. This is the
|
|
// fallback path the exec hook uses when the session model is off. The command
|
|
// runs plain as the unprivileged sandbox user; the provider no longer wraps a
|
|
// user command with the advisory `bwrap` wrapper on any path.
|
|
//
|
|
// `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 gitNet = isGitNetworkCommand(params.command, params.args ?? []);
|
|
const timeoutMs = resolveTimeoutMs(params.timeoutMs, config);
|
|
const effectiveTimeoutMs = gitNet ? Math.min(timeoutMs, GIT_NETWORK_TIMEOUT_MS) : timeoutMs;
|
|
const timeoutSeconds = toTimeoutSeconds(effectiveTimeoutMs);
|
|
const stdinPath = params.stdin != null ? `/tmp/paperclip-stdin-${randomUUID()}` : null;
|
|
|
|
// Marks the start of the `executeCommand` REST round-trip. Hoisted out of the
|
|
// try so the timeout path below can still attribute the exec wall-time it spent
|
|
// before the SDK aborted — a slow failed exec is exactly what we want to
|
|
// measure. Stays null until we are about to call `executeCommand`, so a timeout
|
|
// during the earlier `uploadFile` step honestly reports no `durationMs`.
|
|
let execStart: number | null = null;
|
|
|
|
try {
|
|
if (stdinPath) {
|
|
await sandbox.fs.uploadFile(Buffer.from(params.stdin ?? "", "utf8"), stdinPath, timeoutSeconds);
|
|
}
|
|
|
|
// Run the plain login-shell script as the unprivileged sandbox user. The
|
|
// provider no longer wraps a user command with the advisory `bwrap` wrapper.
|
|
const command = buildLoginShellScript({
|
|
command: params.command,
|
|
args: params.args ?? [],
|
|
cwd: params.cwd,
|
|
env: params.env,
|
|
stdinPath: stdinPath ?? undefined,
|
|
});
|
|
|
|
// Pass cwd undefined: `buildLoginShellScript` already injects the `cd` after
|
|
// it sources the login profiles, when params.cwd is set. The Daytona
|
|
// executor's own cwd argument runs before that profile sourcing, which is
|
|
// the wrong order (a profile could reset the caller env).
|
|
// Time only the `executeCommand` REST round-trip so the caller can
|
|
// attribute a step's exec time to the provider boundary through the
|
|
// free-form `metadata.durationMs`.
|
|
execStart = timingNow();
|
|
const result = await sandbox.process.executeCommand(command, undefined, undefined, timeoutSeconds);
|
|
const durationMs = timingNow() - execStart;
|
|
|
|
return {
|
|
exitCode: typeof result.exitCode === "number" ? result.exitCode : 1,
|
|
timedOut: false,
|
|
stdout: result.result ?? result.artifacts?.stdout ?? "",
|
|
stderr: "",
|
|
metadata: { durationMs },
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof DaytonaTimeoutError) {
|
|
const timeoutMessage = gitNet
|
|
? `Git network operation timed out after ${Math.round(effectiveTimeoutMs / 1000)} s — the remote may be unreachable or noninteractive credentials are not configured.`
|
|
: error.message.trim();
|
|
// Preserve provider-boundary exec attribution on the timeout path: if the
|
|
// SDK aborted the `executeCommand` call itself, report how long it ran
|
|
// before timing out so slow failed startup exec is attributed to the
|
|
// provider, not silently dropped.
|
|
const durationMs = execStart != null ? timingNow() - execStart : undefined;
|
|
return {
|
|
exitCode: null,
|
|
timedOut: true,
|
|
stdout: "",
|
|
stderr: `${timeoutMessage}\n`,
|
|
...(durationMs != null ? { metadata: { durationMs } } : {}),
|
|
};
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (stdinPath) {
|
|
await sandbox.fs.deleteFile(stdinPath).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Poll interval for a session command's exit code. The live spike measured a
|
|
// session command resolving in about 260-300 ms, so a short interval keeps the
|
|
// poll responsive without a busy loop.
|
|
const SESSION_POLL_INTERVAL_MS = 50;
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// Backoff delays for the exit-code read after the log stream ends. The live
|
|
// spike measured the exit code available within one poll (91-202 ms), so the
|
|
// first read almost always holds the code. These delays cover the rare case
|
|
// where the first read has no code yet.
|
|
const SESSION_EXIT_CODE_RETRY_DELAYS_MS = [50, 100, 200];
|
|
|
|
// A bounded reconnect for the log stream. A disconnect settles the stream
|
|
// promise as a rejection while the command still runs on the server. One
|
|
// reconnect replays the log from byte 0; the stream buffer drops the replayed
|
|
// prefix by byte offset. After this many reconnects the dispatch falls back to
|
|
// the poll path.
|
|
const MAX_SESSION_STREAM_RECONNECTS = 1;
|
|
|
|
// Buffers the stdout and stderr of one session command from the callback log
|
|
// stream, and drops a replayed prefix by byte offset.
|
|
//
|
|
// The Daytona callback stream replays the whole log from byte 0 after a
|
|
// reconnect (it does not resume from an offset and does not omit earlier
|
|
// bytes). So the buffer tracks the byte count it already holds per stream and
|
|
// drops any replayed bytes that fall before that count. The dedupe runs at the
|
|
// byte level, because Daytona replays the log byte-for-byte. The SDK keeps each
|
|
// multibyte UTF-8 character whole per chunk and per stream, so the delivered
|
|
// byte count always lands on a character boundary and the byte-offset split is
|
|
// safe.
|
|
//
|
|
// The buffer stores each new tail as a separate chunk and joins the chunks one
|
|
// time at read. It does not copy the earlier output on each append, so total
|
|
// buffering work stays linear in the output size, not quadratic.
|
|
function createSessionStreamBuffer(
|
|
onNewTail?: (stream: "stdout" | "stderr", text: string) => void,
|
|
) {
|
|
const streams = {
|
|
stdout: { chunks: [] as Buffer[], length: 0, connectionBytes: 0 },
|
|
stderr: { chunks: [] as Buffer[], length: 0, connectionBytes: 0 },
|
|
};
|
|
|
|
function append(
|
|
streamName: "stdout" | "stderr",
|
|
stream: { chunks: Buffer[]; length: number; connectionBytes: number },
|
|
chunk: string,
|
|
): void {
|
|
const buf = Buffer.from(chunk, "utf8");
|
|
const start = stream.connectionBytes;
|
|
stream.connectionBytes = start + buf.length;
|
|
// The whole chunk falls before the delivered byte count, so it is a replay.
|
|
if (start + buf.length <= stream.length) {
|
|
return;
|
|
}
|
|
// Keep only the new tail. When the whole chunk is new, `start >=
|
|
// stream.length` and the tail is the whole chunk. When the chunk straddles
|
|
// the delivered byte count, the tail starts after the replayed prefix.
|
|
const tail = start >= stream.length ? buf : buf.subarray(stream.length - start);
|
|
stream.chunks.push(tail);
|
|
stream.length += tail.length;
|
|
// Deliver only the genuinely new tail to the live sink, so a replayed
|
|
// prefix on a reconnect never reaches the host twice.
|
|
if (onNewTail && tail.length > 0) {
|
|
onNewTail(streamName, tail.toString("utf8"));
|
|
}
|
|
}
|
|
|
|
return {
|
|
onStdout: (chunk: string) => append("stdout", streams.stdout, chunk),
|
|
onStderr: (chunk: string) => append("stderr", streams.stderr, chunk),
|
|
// Reset the per-connection read cursors after a reconnect, so the replayed
|
|
// prefix drops against the already-delivered byte count.
|
|
resetConnectionCursors(): void {
|
|
streams.stdout.connectionBytes = 0;
|
|
streams.stderr.connectionBytes = 0;
|
|
},
|
|
get stdout(): string {
|
|
return Buffer.concat(streams.stdout.chunks).toString("utf8");
|
|
},
|
|
get stderr(): string {
|
|
return Buffer.concat(streams.stderr.chunks).toString("utf8");
|
|
},
|
|
};
|
|
}
|
|
|
|
type SessionLogStreamResult =
|
|
| { ok: true; stdout: string; stderr: string }
|
|
| { ok: false };
|
|
|
|
// Stream stdout and stderr of one session command from the callback log form.
|
|
// The stream buffer drops a replayed prefix by byte offset on a reconnect. A
|
|
// disconnect rejects the stream promise; the dispatch reconnects a bounded
|
|
// number of times, then reports failure so the caller falls back to the poll
|
|
// path.
|
|
async function runSessionLogStream(
|
|
sandbox: Sandbox,
|
|
sessionId: string,
|
|
commandId: string,
|
|
onNewTail?: (stream: "stdout" | "stderr", text: string) => void,
|
|
): Promise<SessionLogStreamResult> {
|
|
const buffer = createSessionStreamBuffer(onNewTail);
|
|
let reconnects = 0;
|
|
while (true) {
|
|
try {
|
|
await sandbox.process.getSessionCommandLogs(sessionId, commandId, buffer.onStdout, buffer.onStderr);
|
|
return { ok: true, stdout: buffer.stdout, stderr: buffer.stderr };
|
|
} catch {
|
|
if (reconnects >= MAX_SESSION_STREAM_RECONNECTS) {
|
|
return { ok: false };
|
|
}
|
|
reconnects += 1;
|
|
buffer.resetConnectionCursors();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read the exit code one time after the log stream ends. The exit code is
|
|
// available within one poll, so the first read almost always holds it. Add a
|
|
// small bounded retry with backoff only for the rare case where the first read
|
|
// has no code yet. Return null when no read holds a numeric code.
|
|
async function readSessionExitCode(
|
|
sandbox: Sandbox,
|
|
sessionId: string,
|
|
commandId: string,
|
|
): Promise<number | null> {
|
|
const first = await sandbox.process.getSessionCommand(sessionId, commandId);
|
|
if (typeof first.exitCode === "number") {
|
|
return first.exitCode;
|
|
}
|
|
for (const delayMs of SESSION_EXIT_CODE_RETRY_DELAYS_MS) {
|
|
await sleep(delayMs);
|
|
const status = await sandbox.process.getSessionCommand(sessionId, commandId);
|
|
if (typeof status.exitCode === "number") {
|
|
return status.exitCode;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Dispatch one user command into the persistent session and return its true
|
|
// stdout and stderr.
|
|
//
|
|
// The Daytona session is one persistent shell. A top-level `exit N` inside a
|
|
// session command ends that shell, so the next command then fails with "session
|
|
// process has exited". To stop a user `exit` from reaching the session shell,
|
|
// the dispatch wraps the whole login-shell script in a subshell `( ... )`. A
|
|
// user `exit` then ends only the subshell and reports its exit code, and the
|
|
// session shell stays alive. The provider passes the caller cwd and env inside
|
|
// the login-shell script on every command, so it never relies on implicit state
|
|
// that leaks between commands.
|
|
//
|
|
// The SDK exposes no built-in wait for a session command, so the dispatch runs
|
|
// the command with `runAsync: true` and polls `getSessionCommand` until the exit
|
|
// code is set. It then reads true `stdout` and `stderr` from
|
|
// `getSessionCommandLogs`, because the synchronous response fields are optional.
|
|
// The `runAsync: true` path also avoids the known `runAsync: false` login-shell
|
|
// hang.
|
|
async function executeInSession(
|
|
sandbox: Sandbox,
|
|
sessionId: string,
|
|
params: PluginEnvironmentExecuteParams,
|
|
config: DaytonaDriverConfig,
|
|
): Promise<PluginEnvironmentExecuteResult> {
|
|
const gitNet = isGitNetworkCommand(params.command, params.args ?? []);
|
|
const timeoutMs = resolveTimeoutMs(params.timeoutMs, config);
|
|
const effectiveTimeoutMs = gitNet ? Math.min(timeoutMs, GIT_NETWORK_TIMEOUT_MS) : timeoutMs;
|
|
const timeoutSeconds = toTimeoutSeconds(effectiveTimeoutMs);
|
|
const stdinPath = params.stdin != null ? `/tmp/paperclip-stdin-${randomUUID()}` : null;
|
|
|
|
// Marks the start of the session dispatch and poll. The timeout paths report
|
|
// the exec wall-time spent before the abort, so a slow command is still
|
|
// attributed to the provider boundary.
|
|
let execStart: number | null = null;
|
|
|
|
try {
|
|
if (stdinPath) {
|
|
await sandbox.fs.uploadFile(Buffer.from(params.stdin ?? "", "utf8"), stdinPath, timeoutSeconds);
|
|
}
|
|
|
|
const loginScript = buildLoginShellScript({
|
|
command: params.command,
|
|
args: params.args ?? [],
|
|
cwd: params.cwd,
|
|
env: params.env,
|
|
stdinPath: stdinPath ?? undefined,
|
|
});
|
|
// Subshell wrap: a top-level `exit` in the user command exits only the
|
|
// subshell, not the persistent session shell.
|
|
const command = `( ${loginScript} )`;
|
|
|
|
execStart = timingNow();
|
|
const dispatched = await sandbox.process.executeSessionCommand(
|
|
sessionId,
|
|
{ command, runAsync: true },
|
|
timeoutSeconds,
|
|
);
|
|
const commandId = dispatched.cmdId;
|
|
|
|
// Log-stream path. A session command always tries the stream first: it
|
|
// streams stdout and stderr from the callback log form, then reads the exit
|
|
// code one time. On a stream failure, fall through to the poll path below,
|
|
// because the command still runs to its exit on the server.
|
|
//
|
|
// Emit each genuinely new output chunk to the host during the active execute
|
|
// call. The host routes it to the runner log sink by the host-issued
|
|
// invocation id. This is a no-op when no plugin context is set (a direct
|
|
// test call) or when the host has no active execute route.
|
|
const streamResult = await runSessionLogStream(
|
|
sandbox,
|
|
sessionId,
|
|
commandId,
|
|
(stream, text) => pluginContext?.execution.log(stream, text),
|
|
);
|
|
if (streamResult.ok) {
|
|
const exitCode = await readSessionExitCode(sandbox, sessionId, commandId);
|
|
const durationMs = timingNow() - execStart;
|
|
return {
|
|
exitCode,
|
|
timedOut: false,
|
|
stdout: streamResult.stdout,
|
|
stderr: streamResult.stderr,
|
|
metadata: { durationMs },
|
|
};
|
|
}
|
|
|
|
// Poll for the exit code; the SDK has no wait method. The poll deadline uses
|
|
// the wall clock, separate from the injected timing clock that measures the
|
|
// reported `durationMs`. The poll path is the fallback when the log stream
|
|
// fails.
|
|
const deadlineMs = Date.now() + effectiveTimeoutMs;
|
|
let exitCode: number | null = null;
|
|
while (true) {
|
|
const status = await sandbox.process.getSessionCommand(sessionId, commandId);
|
|
if (typeof status.exitCode === "number") {
|
|
exitCode = status.exitCode;
|
|
break;
|
|
}
|
|
if (Date.now() >= deadlineMs) {
|
|
const durationMs = timingNow() - execStart;
|
|
const timeoutMessage = gitNet
|
|
? `Git network operation timed out after ${Math.round(effectiveTimeoutMs / 1000)} s — the remote may be unreachable or noninteractive credentials are not configured.`
|
|
: `Command timed out after ${Math.round(effectiveTimeoutMs / 1000)} s.`;
|
|
return {
|
|
exitCode: null,
|
|
timedOut: true,
|
|
stdout: "",
|
|
stderr: `${timeoutMessage}\n`,
|
|
metadata: { durationMs },
|
|
};
|
|
}
|
|
await sleep(SESSION_POLL_INTERVAL_MS);
|
|
}
|
|
|
|
// Read true, separated stdout and stderr from the logs endpoint. The
|
|
// synchronous dispatch response fields are optional, so the logs endpoint is
|
|
// the source of truth.
|
|
const logs = await sandbox.process.getSessionCommandLogs(sessionId, commandId);
|
|
const durationMs = timingNow() - execStart;
|
|
return {
|
|
exitCode,
|
|
timedOut: false,
|
|
stdout: logs.stdout ?? "",
|
|
stderr: logs.stderr ?? "",
|
|
metadata: { durationMs },
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof DaytonaTimeoutError) {
|
|
const timeoutMessage = gitNet
|
|
? `Git network operation timed out after ${Math.round(effectiveTimeoutMs / 1000)} s — the remote may be unreachable or noninteractive credentials are not configured.`
|
|
: error.message.trim();
|
|
const durationMs = execStart != null ? timingNow() - execStart : undefined;
|
|
return {
|
|
exitCode: null,
|
|
timedOut: true,
|
|
stdout: "",
|
|
stderr: `${timeoutMessage}\n`,
|
|
...(durationMs != null ? { metadata: { durationMs } } : {}),
|
|
};
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (stdinPath) {
|
|
await sandbox.fs.deleteFile(stdinPath).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The worker-side registry of live login pseudo-terminal sessions.
|
|
// The worker registers each terminal under the host-owned route identifier at
|
|
// create time, so the host closes the exact terminal by that identifier even
|
|
// when the open reply was lost. It also indexes by the worker session identifier
|
|
// for input and stop. The `onShutdown` hook closes every open session here.
|
|
interface DaytonaLoginPtyEntry {
|
|
hostRouteId: string;
|
|
workerSessionId: string;
|
|
session: LoginPtyWorkerSession;
|
|
}
|
|
const daytonaLoginPtyByRoute = new Map<string, DaytonaLoginPtyEntry>();
|
|
const daytonaLoginPtyBySession = new Map<string, DaytonaLoginPtyEntry>();
|
|
|
|
function forgetDaytonaLoginPty(entry: DaytonaLoginPtyEntry): void {
|
|
daytonaLoginPtyByRoute.delete(entry.hostRouteId);
|
|
daytonaLoginPtyBySession.delete(entry.workerSessionId);
|
|
}
|
|
|
|
// The worker-side registry of live duplex channels. The worker registers each
|
|
// channel under the host-owned route identifier at open time, so the host closes
|
|
// the exact channel by that identifier even when the open reply was lost. It also
|
|
// indexes by the worker session identifier for write and stop. Each entry records
|
|
// the provider lease id, so a lease teardown closes only its own channels. The
|
|
// `onShutdown` hook closes every open channel here.
|
|
interface DaytonaDuplexChannelEntry {
|
|
hostRouteId: string;
|
|
workerSessionId: string;
|
|
providerLeaseId: string;
|
|
session: DuplexChannelSession;
|
|
}
|
|
const daytonaDuplexChannelByRoute = new Map<string, DaytonaDuplexChannelEntry>();
|
|
const daytonaDuplexChannelBySession = new Map<string, DaytonaDuplexChannelEntry>();
|
|
|
|
function forgetDaytonaDuplexChannel(entry: DaytonaDuplexChannelEntry): void {
|
|
daytonaDuplexChannelByRoute.delete(entry.hostRouteId);
|
|
daytonaDuplexChannelBySession.delete(entry.workerSessionId);
|
|
}
|
|
|
|
// Close every open duplex channel that belongs to one provider lease and drop its
|
|
// entry. The lease teardown hooks (release, destroy, resume) call this, so a
|
|
// channel never outlives the sandbox that carries it. The close kills the child
|
|
// and releases the pseudo-terminal socket, so no live channel survives the
|
|
// teardown. The stored identifiers are always cleared, so no orphan id survives.
|
|
async function closeDaytonaDuplexChannelsForLease(providerLeaseId: string): Promise<void> {
|
|
const matches = [...daytonaDuplexChannelByRoute.values()].filter(
|
|
(entry) => entry.providerLeaseId === providerLeaseId,
|
|
);
|
|
for (const entry of matches) {
|
|
forgetDaytonaDuplexChannel(entry);
|
|
await entry.session.close().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
const plugin = definePlugin({
|
|
async setup(ctx) {
|
|
// Hoist the context to a module variable so the lifecycle hooks and the
|
|
// file-sync helpers can read `ctx.tracer` — they have no closure over `ctx`.
|
|
pluginContext = 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.");
|
|
}
|
|
// A value of 0 or less disables the extra bound on purpose; reject only a
|
|
// value above the outer RPC ceiling, which would make the bound useless.
|
|
if (config.livenessTimeoutMs > 86_400_000) {
|
|
errors.push("livenessTimeoutMs must be less than or equal to 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.");
|
|
}
|
|
const resourceRequestError = validateResourceRequest(config);
|
|
if (resourceRequestError) {
|
|
errors.push(resourceRequestError);
|
|
}
|
|
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));
|
|
// Configure a provider-side destroy time at or before a caller deadline, so
|
|
// an abandoned sandbox self-destroys even if Paperclip is down. The lease
|
|
// carries the real provider expiry (or none) as evidence of the bound.
|
|
const expiresAt = await configureSandboxExpiry({
|
|
sandbox,
|
|
requestedExpiresAt: params.requestedExpiresAt,
|
|
nowMs: Date.now(),
|
|
});
|
|
const workspaceSentinel = await writeWorkspaceSentinel({
|
|
sandbox,
|
|
remoteCwd,
|
|
params,
|
|
config,
|
|
timeoutSeconds: toTimeoutSeconds(config.timeoutMs),
|
|
});
|
|
sandboxHandleLeaseAdmissionStates.open({
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: sandbox.id,
|
|
config,
|
|
});
|
|
// Seed the handle cache with the fresh handle under the exact scope that
|
|
// `onEnvironmentRealizeWorkspace` reads (providerLeaseId === sandbox.id).
|
|
// Realize then reuses this handle instead of paying a real `client.get`.
|
|
sandboxHandleCache.seed(
|
|
{
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: sandbox.id,
|
|
config,
|
|
},
|
|
sandbox,
|
|
);
|
|
return {
|
|
providerLeaseId: sandbox.id,
|
|
expiresAt,
|
|
metadata: leaseMetadata({
|
|
config,
|
|
sandbox,
|
|
shellCommand,
|
|
remoteCwd,
|
|
resumedLease: false,
|
|
workspaceSentinel,
|
|
}),
|
|
};
|
|
} catch (error) {
|
|
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async onEnvironmentResumeLease(
|
|
params: PluginEnvironmentResumeLeaseParams,
|
|
): Promise<PluginEnvironmentLease> {
|
|
const config = parseDriverConfig(params.config);
|
|
const scope: SandboxScope = {
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: params.providerLeaseId,
|
|
config,
|
|
};
|
|
return await withSandboxActivityGate(scope, async () => {
|
|
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
|
if (!sandbox) {
|
|
return { providerLeaseId: null, metadata: { expired: true } };
|
|
}
|
|
|
|
// A stopped sandbox loses its session shell, so the stored session id is
|
|
// stale after a real restart. Clear the id only when the sandbox is not
|
|
// already running, and clear it before the restart. A stopped sandbox has
|
|
// no live session, so the clear drops a dead id and a later command opens
|
|
// a fresh session. A running sandbox keeps its live session, so the resume
|
|
// leaves the id in place; a concurrent command still finds it and teardown
|
|
// deletes one session. An unconditional clear would drop the id of a live
|
|
// session and leak its shell until sandbox reaping.
|
|
if (sandbox.state !== "started") {
|
|
sandboxHandleSessionStore.clear(scope);
|
|
// A stopped sandbox loses its pseudo-terminals, so a stored duplex channel
|
|
// is dead after a real restart. Close and drop every channel on this lease
|
|
// before the restart, so no stale channel id survives the resume.
|
|
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
|
|
}
|
|
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
try {
|
|
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
|
// C3: a resumed lease must clear the workspace sentinel before it is
|
|
// trusted, even when the handle came from the cache. On any non-match we
|
|
// evict the cached handle and expire the lease so a stale/foreign sandbox
|
|
// is never reused on the subsequent (sentinel-skipping) exec path.
|
|
const workspaceSentinel = await verifyWorkspaceSentinel({
|
|
sandbox,
|
|
remoteCwd,
|
|
leaseMetadata: params.leaseMetadata,
|
|
timeoutSeconds: toTimeoutSeconds(config.timeoutMs),
|
|
});
|
|
if (workspaceSentinel.result !== "matched") {
|
|
evictSandboxHandle(scope);
|
|
return { providerLeaseId: null, metadata: { expired: true, workspaceSentinel } };
|
|
}
|
|
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
sandboxHandleCache.markFresh(scope);
|
|
sandboxHandleLeaseAdmissionStates.open(scope);
|
|
return {
|
|
providerLeaseId: sandbox.id,
|
|
metadata: leaseMetadata({
|
|
config,
|
|
sandbox,
|
|
shellCommand,
|
|
remoteCwd,
|
|
resumedLease: true,
|
|
workspaceSentinel,
|
|
}),
|
|
};
|
|
} catch (error) {
|
|
evictSandboxHandle(scope);
|
|
// A timeout, rate limit, or provider 5xx does not prove this sandbox is
|
|
// lost. Preserve the exact resource and let the host retry its recorded
|
|
// lease; replacement is permitted only after an explicit not-found or
|
|
// an immutable workspace identity mismatch.
|
|
throw error;
|
|
}
|
|
}, { allowClosed: true });
|
|
},
|
|
|
|
async onEnvironmentReleaseLease(
|
|
params: PluginEnvironmentReleaseLeaseParams,
|
|
): Promise<void> {
|
|
if (!params.providerLeaseId) return;
|
|
const config = parseDriverConfig(params.config);
|
|
const scope: SandboxScope = {
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: params.providerLeaseId,
|
|
config,
|
|
};
|
|
// C4: the lease's handle must not outlive its teardown. A teardown gate
|
|
// blocks fresh cache reads while cleanup is in flight so overlapping
|
|
// exec/sync calls cannot reacquire the same sandbox mid-stop/delete.
|
|
const teardownGate = sandboxHandleTeardownGates.begin(scope);
|
|
sandboxHandleLeaseAdmissionStates.close(scope);
|
|
try {
|
|
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
|
if (!sandbox) return;
|
|
|
|
evictSandboxHandle(scope);
|
|
await sandboxHandleActivityGates.waitForIdle(scope);
|
|
await teardownSession(sandbox, scope);
|
|
// Close every duplex channel on this lease before the stop or the delete,
|
|
// so no channel outlives the sandbox and no stored channel id survives.
|
|
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
|
|
|
|
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;
|
|
}
|
|
|
|
if (config.archiveOnRelease) {
|
|
try {
|
|
if (sandbox.state !== "stopped") {
|
|
await sandbox.stop(toTimeoutSeconds(config.timeoutMs));
|
|
}
|
|
await sandbox.setAutoDeleteInterval(ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES);
|
|
await sandbox.archive();
|
|
return;
|
|
} catch (error) {
|
|
console.warn(
|
|
`Failed to archive Daytona sandbox during lease release: ${formatErrorMessage(error)}. Falling back to delete.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
|
|
} finally {
|
|
sandboxHandleTeardownGates.end(scope, teardownGate);
|
|
evictSandboxHandle(scope);
|
|
}
|
|
},
|
|
|
|
async onEnvironmentDestroyLease(
|
|
params: PluginEnvironmentDestroyLeaseParams,
|
|
): Promise<void> {
|
|
if (!params.providerLeaseId) return;
|
|
const config = parseDriverConfig(params.config);
|
|
const scope: SandboxScope = {
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: params.providerLeaseId,
|
|
config,
|
|
};
|
|
// C4: the teardown gate blocks fresh cache reads while delete is in flight
|
|
// so overlapping exec/sync calls cannot reacquire the same sandbox mid-teardown.
|
|
const teardownGate = sandboxHandleTeardownGates.begin(scope);
|
|
sandboxHandleLeaseAdmissionStates.close(scope);
|
|
try {
|
|
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
|
if (!sandbox) return;
|
|
|
|
evictSandboxHandle(scope);
|
|
await sandboxHandleActivityGates.waitForIdle(scope);
|
|
await teardownSession(sandbox, scope);
|
|
// Close every duplex channel on this lease before the delete, so no channel
|
|
// outlives the sandbox and no stored channel id survives.
|
|
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
|
|
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
|
|
} finally {
|
|
sandboxHandleTeardownGates.end(scope, teardownGate);
|
|
evictSandboxHandle(scope);
|
|
}
|
|
},
|
|
|
|
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 scope: SandboxScope = {
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: params.lease.providerLeaseId,
|
|
config,
|
|
};
|
|
await withSandboxActivityGate(scope, async () => {
|
|
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
|
|
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
await sandbox.fs.createFolder(remoteCwd, "755");
|
|
});
|
|
}
|
|
|
|
return {
|
|
cwd: remoteCwd,
|
|
metadata: {
|
|
provider: "daytona",
|
|
remoteCwd,
|
|
},
|
|
};
|
|
},
|
|
|
|
async onEnvironmentStartInteractiveSetup(
|
|
params: PluginEnvironmentStartInteractiveSetupParams,
|
|
): Promise<PluginEnvironmentInteractiveSetupSession> {
|
|
const baseConfig = parseDriverConfig(params.config);
|
|
const config = withSetupSourceTemplate(baseConfig, params);
|
|
const sandbox = await createSandbox(params, config, { purpose: "interactive_setup" });
|
|
try {
|
|
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
|
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
const connection = await createSshConnection(
|
|
sandbox,
|
|
resolveConnectionExpiresInMinutes(params.connectionExpiresInMinutes),
|
|
);
|
|
sandboxHandleLeaseAdmissionStates.open({
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: sandbox.id,
|
|
config,
|
|
});
|
|
return {
|
|
providerLeaseId: sandbox.id,
|
|
status: "waiting_for_user",
|
|
expiresAt: params.expiresAt ?? connection.connectionPayload?.expiresAt ?? null,
|
|
...connection,
|
|
metadata: interactiveSetupMetadata({
|
|
config,
|
|
sandbox,
|
|
shellCommand,
|
|
remoteCwd,
|
|
sourceTemplateRef: params.sourceTemplateRef,
|
|
}),
|
|
};
|
|
} catch (error) {
|
|
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async onEnvironmentGetInteractiveSetup(
|
|
params: PluginEnvironmentGetInteractiveSetupParams,
|
|
): Promise<PluginEnvironmentInteractiveSetupSession> {
|
|
const config = parseDriverConfig(params.config);
|
|
if (!params.providerLeaseId) {
|
|
return {
|
|
providerLeaseId: null,
|
|
status: "missing",
|
|
connectionSummary: null,
|
|
connectionPayload: null,
|
|
metadata: {
|
|
provider: "daytona",
|
|
missing: true,
|
|
},
|
|
};
|
|
}
|
|
const scope = {
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: params.providerLeaseId,
|
|
config,
|
|
};
|
|
return await withSandboxActivityGate(scope, async () => {
|
|
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
|
if (!sandbox) {
|
|
return {
|
|
providerLeaseId: null,
|
|
status: "missing",
|
|
connectionSummary: null,
|
|
connectionPayload: null,
|
|
metadata: {
|
|
provider: "daytona",
|
|
missing: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
|
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
const connection = params.includeConnectionPayload === true
|
|
? await createSshConnection(sandbox, resolveConnectionExpiresInMinutes(params.connectionExpiresInMinutes))
|
|
: {
|
|
connectionSummary: {
|
|
type: "ssh" as const,
|
|
username: "token",
|
|
hostRedacted: true,
|
|
portRedacted: true,
|
|
commandRedacted: true,
|
|
metadata: {
|
|
provider: "daytona",
|
|
},
|
|
},
|
|
connectionPayload: null,
|
|
};
|
|
|
|
return {
|
|
providerLeaseId: sandbox.id,
|
|
status: "waiting_for_user",
|
|
...connection,
|
|
metadata: interactiveSetupMetadata({
|
|
config,
|
|
sandbox,
|
|
shellCommand,
|
|
remoteCwd,
|
|
}),
|
|
};
|
|
});
|
|
},
|
|
|
|
async onEnvironmentCaptureTemplate(
|
|
params: PluginEnvironmentCaptureTemplateParams,
|
|
): Promise<PluginEnvironmentCaptureTemplateResult> {
|
|
const config = parseDriverConfig(params.config);
|
|
if (!params.providerLeaseId) {
|
|
throw new Error("Cannot capture a Daytona template without a setup sandbox lease.");
|
|
}
|
|
const scope = {
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: params.providerLeaseId,
|
|
config,
|
|
};
|
|
return await withSandboxActivityGate(scope, async () => {
|
|
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
|
|
const createSnapshot = (sandbox as DaytonaInteractiveSandbox)._experimental_createSnapshot;
|
|
if (typeof createSnapshot !== "function") {
|
|
throw new Error(
|
|
"Daytona template capture requires @daytonaio/sdk Sandbox._experimental_createSnapshot support.",
|
|
);
|
|
}
|
|
const templateRef = sanitizeSnapshotName(
|
|
params.templateLabel,
|
|
`paperclip-${params.environmentId}-${randomUUID().slice(0, 8)}`,
|
|
);
|
|
const timeoutMs = typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
|
|
? Math.trunc(params.timeoutMs)
|
|
: config.timeoutMs;
|
|
|
|
await createSnapshot.call(sandbox, templateRef, toTimeoutSeconds(timeoutMs));
|
|
|
|
return {
|
|
templateKind: "snapshot",
|
|
templateRef,
|
|
metadata: {
|
|
provider: "daytona",
|
|
sandboxId: sandbox.id,
|
|
capturedAt: new Date().toISOString(),
|
|
sourceTemplateRefRedacted: Boolean(params.sourceTemplateRef),
|
|
previousTemplateRefRedacted: Boolean(params.previousTemplateRef),
|
|
timeoutMs,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
async onEnvironmentCancelInteractiveSetup(
|
|
params: PluginEnvironmentCancelInteractiveSetupParams,
|
|
): Promise<PluginEnvironmentCancelInteractiveSetupResult> {
|
|
const config = parseDriverConfig(params.config);
|
|
if (!params.providerLeaseId) {
|
|
return {
|
|
status: "missing",
|
|
metadata: {
|
|
provider: "daytona",
|
|
missing: true,
|
|
reason: params.reason ?? null,
|
|
},
|
|
};
|
|
}
|
|
const scope: SandboxScope = {
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: params.providerLeaseId,
|
|
config,
|
|
};
|
|
// C4: cancelling an interactive-setup lease deletes the sandbox, so the
|
|
// teardown gate blocks fresh cache reads while delete is in flight.
|
|
const teardownGate = sandboxHandleTeardownGates.begin(scope);
|
|
sandboxHandleLeaseAdmissionStates.close(scope);
|
|
try {
|
|
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
|
if (!sandbox) {
|
|
return {
|
|
status: "missing",
|
|
metadata: {
|
|
provider: "daytona",
|
|
missing: true,
|
|
reason: params.reason ?? null,
|
|
},
|
|
};
|
|
}
|
|
evictSandboxHandle(scope);
|
|
await sandboxHandleActivityGates.waitForIdle(scope);
|
|
await teardownSession(sandbox, scope);
|
|
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
|
|
return {
|
|
status: params.reason === "timed_out" ? "timed_out" : "cancelled",
|
|
metadata: {
|
|
provider: "daytona",
|
|
sandboxId: sandbox.id,
|
|
reason: params.reason ?? null,
|
|
},
|
|
};
|
|
} finally {
|
|
sandboxHandleTeardownGates.end(scope, teardownGate);
|
|
evictSandboxHandle(scope);
|
|
}
|
|
},
|
|
|
|
async onEnvironmentDeleteTemplate(
|
|
params: PluginEnvironmentDeleteTemplateParams,
|
|
): Promise<PluginEnvironmentDeleteTemplateResult> {
|
|
const templateKind = params.templateKind ?? "snapshot";
|
|
if (templateKind !== "snapshot") {
|
|
throw new Error(`Daytona can delete snapshot templates only, not ${templateKind}.`);
|
|
}
|
|
const config = parseDriverConfig(params.config);
|
|
const client = createDaytonaClient(config) as Daytona & { snapshot?: DaytonaSnapshotService };
|
|
const snapshotService = client.snapshot;
|
|
if (typeof snapshotService?.get !== "function" || typeof snapshotService.delete !== "function") {
|
|
throw new Error("Daytona template deletion requires @daytonaio/sdk snapshot.get/delete support.");
|
|
}
|
|
const snapshot = await snapshotService.get(params.templateRef);
|
|
await snapshotService.delete(snapshot);
|
|
return {
|
|
deleted: true,
|
|
metadata: {
|
|
provider: "daytona",
|
|
templateKind: "snapshot",
|
|
templateRefRedacted: true,
|
|
reason: params.reason ?? null,
|
|
},
|
|
};
|
|
},
|
|
|
|
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 providerLeaseId = params.lease.providerLeaseId;
|
|
return await withSandboxActivityGate({
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId,
|
|
config,
|
|
}, async () => {
|
|
// Time the sandbox handle lookup (Open Q1) separately from the
|
|
// `executeCommand` round-trip so telemetry can split the per-call get cost
|
|
// from the exec cost. With the per-lease handle cache this collapses to ~0
|
|
// on a hit (no `client.get` REST round-trip), but the field stays present so
|
|
// `providerGetMs` remains observable — and still captures the occasional
|
|
// freshness refresh the cache issues after an idle gap. `ensureSandboxStarted`
|
|
// is a no-op for an already-started sandbox, so it is excluded from the get
|
|
// measurement.
|
|
const getStart = timingNow();
|
|
// Decide the explicit `cache_hit` flag at the true cache decision: the
|
|
// handle lookup reports whether the warm cache served the handle or the
|
|
// lookup called `client.get`. This replaces the old `providerGetMs == 0`
|
|
// proxy. The default `false` covers the theoretical case where the lookup
|
|
// reports nothing.
|
|
let cacheHit = false;
|
|
const sandbox = await getSandbox({
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId,
|
|
config,
|
|
}, {
|
|
bypassTeardownGate: true,
|
|
onCacheDecision: (hit) => {
|
|
cacheHit = hit;
|
|
},
|
|
});
|
|
const getDurationMs = timingNow() - getStart;
|
|
const scope: SandboxScope = {
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId,
|
|
config,
|
|
};
|
|
if (sandbox.state !== "started") {
|
|
// A provider restart destroys Daytona process sessions. Drop the stale
|
|
// session id before starting the sandbox so runnerd recovery opens a
|
|
// new session instead of retrying a dead one for its whole grace.
|
|
sandboxHandleSessionStore.clear(scope);
|
|
}
|
|
await ensureSandboxStarted(sandbox, toTimeoutSeconds(resolveTimeoutMs(params.timeoutMs, config)));
|
|
// Dispatch the command. A normal command runs in the persistent session:
|
|
// the provider opens the one session on a cache miss and runs every command
|
|
// in it. The provider never falls back to a one-shot command to open a
|
|
// session; a cache miss creates one.
|
|
//
|
|
// A `bypassSession` command runs one-shot and does NOT open the session.
|
|
// The host sets this flag on a pre-run command (the workspace provision
|
|
// command) that runs before the run opens its trace root. Opening the
|
|
// session there would emit a `session.open` span with no run parent, and
|
|
// the span backend would drop it. With the bypass the session opens on the
|
|
// first in-run command, whose open span parents to the run trace.
|
|
let result: PluginEnvironmentExecuteResult;
|
|
if (!params.bypassSession) {
|
|
const sessionId = await getOrCreateSession(sandbox, scope);
|
|
result = await executeInSession(sandbox, sessionId, params, config);
|
|
} else {
|
|
result = await executeOneShot(sandbox, params, config);
|
|
}
|
|
if (!result.timedOut) {
|
|
sandboxHandleCache.markFresh(scope);
|
|
}
|
|
return {
|
|
...result,
|
|
metadata: { ...(result.metadata ?? {}), getDurationMs, cacheHit },
|
|
};
|
|
});
|
|
},
|
|
|
|
async onEnvironmentRunnerIngressEndpoint(
|
|
params: PluginEnvironmentRunnerIngressEndpointParams,
|
|
): Promise<PluginEnvironmentRunnerIngressEndpoint> {
|
|
if (params.port !== 43_127) {
|
|
throw new Error("Daytona runner ingress must use fixed port 43127.");
|
|
}
|
|
if (!/^\/api\/runner\/v1\/connect\/[^/?#]+$/.test(params.path)) {
|
|
throw new Error("Daytona runner ingress path is invalid.");
|
|
}
|
|
const providerLeaseId = params.lease.providerLeaseId;
|
|
if (!providerLeaseId) {
|
|
throw new Error("Daytona runner ingress requires a provider lease id.");
|
|
}
|
|
const config = parseDriverConfig(params.config);
|
|
return await withSandboxActivityGate(
|
|
{
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId,
|
|
config,
|
|
},
|
|
async () => {
|
|
const sandbox = await getSandbox({
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId,
|
|
config,
|
|
});
|
|
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
await withLivenessTimeout(
|
|
"sandbox.refreshData",
|
|
config.livenessTimeoutMs,
|
|
() => sandbox.refreshData(),
|
|
);
|
|
const preview = await sandbox.getPreviewLink(params.port);
|
|
if (typeof preview.url !== "string" || typeof preview.token !== "string") {
|
|
throw new Error("Daytona returned an incomplete private preview endpoint.");
|
|
}
|
|
const url = new URL(preview.url);
|
|
if (
|
|
url.protocol !== "https:" ||
|
|
url.username ||
|
|
url.password ||
|
|
url.search ||
|
|
url.hash
|
|
) {
|
|
throw new Error("Daytona returned an invalid private preview URL.");
|
|
}
|
|
url.protocol = "wss:";
|
|
url.pathname = `${url.pathname.replace(/\/$/, "")}${params.path}`;
|
|
return {
|
|
kind: "authenticated_websocket",
|
|
websocketUrl: url.toString(),
|
|
secretHeaders: [
|
|
{ name: "X-Daytona-Preview-Token", value: preview.token },
|
|
],
|
|
generation: runnerIngressGenerationStore.get(sandbox),
|
|
};
|
|
},
|
|
);
|
|
},
|
|
|
|
// Opt-in native inbound transfer. Defining this hook (with onEnvironmentSyncOut)
|
|
// makes the worker advertise `environmentSyncIn`/`environmentSyncOut`, so the
|
|
// host runner routes Daytona workspace/asset transfers through the SDK's batch
|
|
// `uploadFiles` (plus host-side tarballs for directories) instead of the
|
|
// base64-over-exec fallback. Providers that do not define these keep the
|
|
// byte-identical fallback.
|
|
async onEnvironmentSyncIn(
|
|
params: PluginEnvironmentSyncInParams,
|
|
): Promise<PluginEnvironmentSyncResult> {
|
|
if (!params.lease.providerLeaseId) {
|
|
throw new Error("Daytona syncIn requires a provider lease ID.");
|
|
}
|
|
const config = parseDriverConfig(params.config);
|
|
const remoteDir = resolveSyncRemoteDir(params.lease);
|
|
const timeoutSeconds = toTimeoutSeconds(config.timeoutMs);
|
|
const scope = {
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: params.lease.providerLeaseId,
|
|
config,
|
|
};
|
|
// Collect the advisory read-write destinations for this scope. This records
|
|
// intent only; it does not change the transfer below.
|
|
sandboxHandleWritableDirs.recordWritableTargets(scope, params.operations);
|
|
return await withSandboxActivityGate(scope, async () => {
|
|
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
|
|
await ensureSandboxStarted(sandbox, timeoutSeconds);
|
|
const result = await performSyncIn({
|
|
sandbox,
|
|
operations: params.operations,
|
|
remoteDir,
|
|
timeoutSeconds,
|
|
});
|
|
sandboxHandleCache.markFresh(scope);
|
|
return result;
|
|
});
|
|
},
|
|
|
|
// Opt-in native outbound transfer. See onEnvironmentSyncIn.
|
|
async onEnvironmentSyncOut(
|
|
params: PluginEnvironmentSyncOutParams,
|
|
): Promise<PluginEnvironmentSyncResult> {
|
|
if (!params.lease.providerLeaseId) {
|
|
throw new Error("Daytona syncOut requires a provider lease ID.");
|
|
}
|
|
const config = parseDriverConfig(params.config);
|
|
const remoteDir = resolveSyncRemoteDir(params.lease);
|
|
const timeoutSeconds = toTimeoutSeconds(config.timeoutMs);
|
|
const scope = {
|
|
driverKey: params.driverKey,
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
providerLeaseId: params.lease.providerLeaseId,
|
|
config,
|
|
};
|
|
return await withSandboxActivityGate(scope, async () => {
|
|
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
|
|
await ensureSandboxStarted(sandbox, timeoutSeconds);
|
|
const result = await performSyncOut({
|
|
sandbox,
|
|
operations: params.operations,
|
|
remoteDir,
|
|
timeoutSeconds,
|
|
});
|
|
sandboxHandleCache.markFresh(scope);
|
|
return result;
|
|
});
|
|
},
|
|
|
|
// Open one live login pseudo-terminal. Resolve the cached sandbox by the
|
|
// provider lease id, revalidate the host launch descriptor, create the session
|
|
// home with one `mkdir -p` command, run the fixed login command on a real
|
|
// pseudo-terminal, and register the session under the host route id. Stream the
|
|
// raw output and the exit through `ctx.loginPty`, bound to the returned worker
|
|
// session id. Fail closed when no cached sandbox matches the lease.
|
|
async onLoginPtyOpen(params) {
|
|
const sandbox = await sandboxHandleCache.findByProviderLeaseId(params.providerLeaseId);
|
|
if (!sandbox) {
|
|
throw new Error(
|
|
"Daytona login pseudo-terminal: no cached sandbox resolves the provider lease.",
|
|
);
|
|
}
|
|
const homeFs = createDaytonaLoginHomeFs(sandbox.process as unknown as DaytonaSandboxExec);
|
|
const session = await openLoginPtySession(
|
|
sandbox.process as unknown as DaytonaPtyProcess,
|
|
homeFs,
|
|
{ loginCommandKey: params.loginCommandKey, sessionHome: params.sessionHome },
|
|
);
|
|
const workerSessionId = `pty-${randomUUID()}`;
|
|
const entry: DaytonaLoginPtyEntry = {
|
|
hostRouteId: params.hostRouteId,
|
|
workerSessionId,
|
|
session,
|
|
};
|
|
daytonaLoginPtyByRoute.set(params.hostRouteId, entry);
|
|
daytonaLoginPtyBySession.set(workerSessionId, entry);
|
|
// Register the output listener before the first input, so no early output
|
|
// chunk is lost. The client stamps the worker session id, so the host binds
|
|
// the output to the open route.
|
|
session.onData((chunk) => {
|
|
pluginContext?.loginPty.output(workerSessionId, chunk);
|
|
});
|
|
// Forward the child exit one time. The host resolves the login run on it.
|
|
void session.wait().then(
|
|
(result) => pluginContext?.loginPty.exit(workerSessionId, result.exitCode),
|
|
() => pluginContext?.loginPty.exit(workerSessionId, null),
|
|
);
|
|
return { workerSessionId };
|
|
},
|
|
|
|
// Write delayed input to an open login pseudo-terminal, keyed by the worker
|
|
// session id. Drop the input for an unknown session.
|
|
async onLoginPtyInput(params) {
|
|
const entry = daytonaLoginPtyBySession.get(params.workerSessionId);
|
|
if (!entry) return;
|
|
entry.session.write(params.data);
|
|
},
|
|
|
|
// Stop an open login pseudo-terminal child, keyed by the worker session id.
|
|
async onLoginPtyStop(params) {
|
|
const entry = daytonaLoginPtyBySession.get(params.workerSessionId);
|
|
if (!entry) return;
|
|
entry.session.kill();
|
|
},
|
|
|
|
// Close an open login pseudo-terminal by the host route id and acknowledge the
|
|
// close with the same identifier. The close is idempotent: it returns the
|
|
// acknowledgement even when the entry is already gone, so the host confirms the
|
|
// terminal is closed. The worker never keys the close on the worker session id.
|
|
async onLoginPtyClose(params) {
|
|
const entry = daytonaLoginPtyByRoute.get(params.hostRouteId);
|
|
if (entry) {
|
|
forgetDaytonaLoginPty(entry);
|
|
await entry.session.close().catch(() => undefined);
|
|
}
|
|
return { hostRouteId: params.hostRouteId };
|
|
},
|
|
|
|
// Open one persistent duplex channel. Resolve the cached sandbox by the provider
|
|
// lease id, run the gateway command on a raw pseudo-terminal, and register the
|
|
// channel under the host route id. Stream the raw data and the exit through
|
|
// `ctx.duplexChannel`, bound to the returned worker session id. Fail closed when
|
|
// no cached sandbox matches the lease.
|
|
async onDuplexChannelOpen(params) {
|
|
const sandbox = await sandboxHandleCache.findByProviderLeaseId(params.providerLeaseId);
|
|
if (!sandbox) {
|
|
throw new Error(
|
|
"Daytona duplex channel: no cached sandbox resolves the provider lease.",
|
|
);
|
|
}
|
|
const session = await openDuplexChannelSession(
|
|
sandbox.process as unknown as DaytonaPtyProcess,
|
|
params.command,
|
|
);
|
|
const workerSessionId = `duplex-${randomUUID()}`;
|
|
const entry: DaytonaDuplexChannelEntry = {
|
|
hostRouteId: params.hostRouteId,
|
|
workerSessionId,
|
|
providerLeaseId: params.providerLeaseId,
|
|
session,
|
|
};
|
|
daytonaDuplexChannelByRoute.set(params.hostRouteId, entry);
|
|
daytonaDuplexChannelBySession.set(workerSessionId, entry);
|
|
// Register the data listener before the first write, so no early data chunk is
|
|
// lost. The client echoes the host route id and the worker session id, so the
|
|
// host routes the data to the exact live pair.
|
|
session.onData((chunk) => {
|
|
pluginContext?.duplexChannel.data(entry.hostRouteId, workerSessionId, chunk);
|
|
});
|
|
// Forward the child exit one time. The host resolves the open route on it. A
|
|
// numeric exit code is a real process exit; a resolved transport close carries
|
|
// `transportClosed`, so the host keeps the two apart in the loss taxonomy. A
|
|
// rejected wait is not a resolved transport close, so it reports no code and no
|
|
// transport-close mark.
|
|
void session.wait().then(
|
|
(result) =>
|
|
pluginContext?.duplexChannel.exit(
|
|
entry.hostRouteId,
|
|
workerSessionId,
|
|
result.exitCode,
|
|
result.transportClosed,
|
|
),
|
|
() => pluginContext?.duplexChannel.exit(entry.hostRouteId, workerSessionId, null),
|
|
);
|
|
// Echo the host route id on the reply, so the host binds the exact pair.
|
|
return { hostRouteId: params.hostRouteId, workerSessionId };
|
|
},
|
|
|
|
// Write host input to an open duplex channel. Act only on the exact live pair.
|
|
// A write whose pair does not match the bound entry applies no bytes.
|
|
//
|
|
// `params.data` arrives in the wire-safe base64 form (JSON carries no binary
|
|
// type; see `ChannelBytesWireValue` in the plugin SDK's protocol.ts). Decode it
|
|
// back to raw bytes before it reaches the pseudo-terminal. A malformed value
|
|
// decodes to `null`; the worker applies no bytes rather than sending an empty
|
|
// write to the sandbox.
|
|
async onDuplexChannelWrite(params) {
|
|
const entry = daytonaDuplexChannelBySession.get(params.workerSessionId);
|
|
if (!entry || entry.hostRouteId !== params.hostRouteId) return;
|
|
const data = decodeChannelBytes(params.data);
|
|
if (data === null) return;
|
|
entry.session.write(data);
|
|
},
|
|
|
|
// Stop an open duplex channel child. Act only on the exact live pair. A stop
|
|
// whose pair does not match the bound entry stops nothing.
|
|
async onDuplexChannelStop(params) {
|
|
const entry = daytonaDuplexChannelBySession.get(params.workerSessionId);
|
|
if (!entry || entry.hostRouteId !== params.hostRouteId) return;
|
|
entry.session.kill();
|
|
},
|
|
|
|
// Close an open duplex channel by the host route id and acknowledge the close.
|
|
// The host route id is the authoritative close key, so a pre-bind close with a
|
|
// lost open reply still closes the channel. On a bound close the acknowledgement
|
|
// echoes the worker session id too, so the host verifies the exact pair. The
|
|
// close is idempotent: it returns the acknowledgement even when the entry is
|
|
// already gone, so the host confirms the channel is closed.
|
|
async onDuplexChannelClose(params) {
|
|
const entry = daytonaDuplexChannelByRoute.get(params.hostRouteId);
|
|
if (entry) {
|
|
const boundWorkerSessionId = entry.workerSessionId;
|
|
forgetDaytonaDuplexChannel(entry);
|
|
await entry.session.close().catch(() => undefined);
|
|
return { hostRouteId: params.hostRouteId, workerSessionId: boundWorkerSessionId };
|
|
}
|
|
return { hostRouteId: params.hostRouteId };
|
|
},
|
|
|
|
// Close every open login pseudo-terminal and every open duplex channel on an
|
|
// orderly shutdown, then drain the sandbox handle cache, so a graceful shutdown
|
|
// holds no live terminal and no live channel.
|
|
async onShutdown() {
|
|
const openSessions = [...daytonaLoginPtyByRoute.values()];
|
|
daytonaLoginPtyByRoute.clear();
|
|
daytonaLoginPtyBySession.clear();
|
|
for (const entry of openSessions) {
|
|
await entry.session.close().catch(() => undefined);
|
|
}
|
|
const openChannels = [...daytonaDuplexChannelByRoute.values()];
|
|
daytonaDuplexChannelByRoute.clear();
|
|
daytonaDuplexChannelBySession.clear();
|
|
for (const entry of openChannels) {
|
|
await entry.session.close().catch(() => undefined);
|
|
}
|
|
sandboxHandleCache.reset();
|
|
runnerIngressGenerationStore.reset();
|
|
},
|
|
});
|
|
|
|
export default plugin;
|