@tickernelz/paperclip-pro-plugin-daytona 2026.925.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/duplex-command-stream.d.ts +97 -0
- package/dist/duplex-command-stream.d.ts.map +1 -0
- package/dist/duplex-command-stream.js +205 -0
- package/dist/duplex-command-stream.js.map +1 -0
- package/dist/duplex-command-stream.live.test.d.ts +2 -0
- package/dist/duplex-command-stream.live.test.d.ts.map +1 -0
- package/dist/duplex-command-stream.live.test.js +324 -0
- package/dist/duplex-command-stream.live.test.js.map +1 -0
- package/dist/duplex-command-stream.test.d.ts +2 -0
- package/dist/duplex-command-stream.test.d.ts.map +1 -0
- package/dist/duplex-command-stream.test.js +519 -0
- package/dist/duplex-command-stream.test.js.map +1 -0
- package/dist/file-sync.d.ts +77 -0
- package/dist/file-sync.d.ts.map +1 -0
- package/dist/file-sync.js +1055 -0
- package/dist/file-sync.js.map +1 -0
- package/dist/file-sync.test.d.ts +2 -0
- package/dist/file-sync.test.d.ts.map +1 -0
- package/dist/file-sync.test.js +974 -0
- package/dist/file-sync.test.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/login-pty.d.ts +162 -0
- package/dist/login-pty.d.ts.map +1 -0
- package/dist/login-pty.js +258 -0
- package/dist/login-pty.js.map +1 -0
- package/dist/login-pty.test.d.ts +2 -0
- package/dist/login-pty.test.d.ts.map +1 -0
- package/dist/login-pty.test.js +319 -0
- package/dist/login-pty.test.js.map +1 -0
- package/dist/manifest.d.ts +4 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +179 -0
- package/dist/manifest.js.map +1 -0
- package/dist/plugin.d.ts +49 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +2563 -0
- package/dist/plugin.js.map +1 -0
- package/dist/plugin.test.d.ts +2 -0
- package/dist/plugin.test.d.ts.map +1 -0
- package/dist/plugin.test.js +4701 -0
- package/dist/plugin.test.js.map +1 -0
- package/dist/pty-chunked-input.d.ts +48 -0
- package/dist/pty-chunked-input.d.ts.map +1 -0
- package/dist/pty-chunked-input.js +74 -0
- package/dist/pty-chunked-input.js.map +1 -0
- package/dist/pty-chunked-input.test.d.ts +2 -0
- package/dist/pty-chunked-input.test.d.ts.map +1 -0
- package/dist/pty-chunked-input.test.js +115 -0
- package/dist/pty-chunked-input.test.js.map +1 -0
- package/dist/worker.d.ts +3 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +5 -0
- package/dist/worker.js.map +1 -0
- package/package.json +44 -0
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,2563 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { Daytona, DaytonaNotFoundError, DaytonaTimeoutError } from "@daytonaio/sdk";
|
|
4
|
+
import { decodeChannelBytes, definePlugin, NOOP_PLUGIN_TRACER } from "@tickernelz/paperclip-pro-plugin-sdk";
|
|
5
|
+
import { performSyncIn, performSyncOut, withProviderSpan } from "./file-sync.js";
|
|
6
|
+
// The Claude `setup-token` login pseudo-terminal (PTY) session for this provider.
|
|
7
|
+
// The session runs the login command on a real pseudo-terminal, streams the
|
|
8
|
+
// terminal output, and delivers the delayed browser code plus the Enter byte. A
|
|
9
|
+
// later phase binds the opener to `sandbox.process` and wraps it with the
|
|
10
|
+
// `createLoginPtyTransport` factory from `@tickernelz/paperclip-pro-adapter-utils` to
|
|
11
|
+
// build the transport the login runner drives.
|
|
12
|
+
export { createDaytonaLoginPtySessionOpener, openDaytonaLoginPtySession, createDaytonaLoginHomeFs, } from "./login-pty.js";
|
|
13
|
+
import { openDaytonaLoginPtySession as openLoginPtySession, createDaytonaLoginHomeFs, } from "./login-pty.js";
|
|
14
|
+
// The Daytona duplex command stream for the sandbox callback bridge. The channel
|
|
15
|
+
// runs the gateway command on a raw pseudo-terminal, streams the frames, and
|
|
16
|
+
// accepts host input. The worker resolves the sandbox by the provider lease id,
|
|
17
|
+
// registers the channel under the host route id, and streams the data and the
|
|
18
|
+
// exit through `ctx.duplexChannel`.
|
|
19
|
+
export { createDaytonaDuplexChannelSessionOpener, openDaytonaDuplexChannelSession, buildDuplexChannelLaunchWrapper, } from "./duplex-command-stream.js";
|
|
20
|
+
import { openDaytonaDuplexChannelSession as openDuplexChannelSession } from "./duplex-command-stream.js";
|
|
21
|
+
// Injectable monotonic clock for provider-boundary timing (Open Q1). Defaults
|
|
22
|
+
// to the real wall clock; `plugin.test.ts` overrides it via
|
|
23
|
+
// `setDaytonaTimingClockForTest` so the measured `durationMs`/`getDurationMs`
|
|
24
|
+
// are deterministic. The timing path never calls `Date.now()` directly.
|
|
25
|
+
let timingNow = () => Date.now();
|
|
26
|
+
// The plugin context, hoisted to a module variable in `setup(ctx)`. The
|
|
27
|
+
// lifecycle hooks and the file-sync helpers have no closure over `ctx`, so they
|
|
28
|
+
// read the tracer through `getPluginTracer()`. Before `setup` runs (or in a
|
|
29
|
+
// test) the tracer is a no-op, so a span never throws.
|
|
30
|
+
let pluginContext = null;
|
|
31
|
+
/**
|
|
32
|
+
* Return the plugin tracer. It is the injected `ctx.tracer` after `setup`, or a
|
|
33
|
+
* no-op before it. A provider span opened through it records only when tracing
|
|
34
|
+
* is on and an active host trace context is present.
|
|
35
|
+
*/
|
|
36
|
+
export function getPluginTracer() {
|
|
37
|
+
return pluginContext?.tracer ?? NOOP_PLUGIN_TRACER;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Test seam: set the module-level plugin context, and return a restore function.
|
|
41
|
+
* `plugin.test.ts` uses it to inject a recording tracer without running `setup`.
|
|
42
|
+
*/
|
|
43
|
+
export function __setDaytonaPluginContextForTest(ctx) {
|
|
44
|
+
const previous = pluginContext;
|
|
45
|
+
pluginContext = ctx;
|
|
46
|
+
return () => {
|
|
47
|
+
pluginContext = previous;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Test seam: override the provider-timing clock and return a restore function.
|
|
52
|
+
* Not used in production, where the default wall clock always applies.
|
|
53
|
+
*/
|
|
54
|
+
export function setDaytonaTimingClockForTest(now) {
|
|
55
|
+
const previous = timingNow;
|
|
56
|
+
timingNow = now;
|
|
57
|
+
return () => {
|
|
58
|
+
timingNow = previous;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// Injectable clock for the handle cache's freshness bookkeeping, deliberately
|
|
62
|
+
// kept separate from the provider-timing clock so tests can advance virtual time
|
|
63
|
+
// past a lease's auto-stop interval without perturbing the `getDurationMs` /
|
|
64
|
+
// `durationMs` measurements that ride on `timingNow`.
|
|
65
|
+
let handleFreshnessNow = () => Date.now();
|
|
66
|
+
/**
|
|
67
|
+
* Test seam: override the handle-cache freshness clock and return a restore
|
|
68
|
+
* function. Not used in production, where the default wall clock always applies.
|
|
69
|
+
*/
|
|
70
|
+
export function setDaytonaHandleFreshnessClockForTest(now) {
|
|
71
|
+
const previous = handleFreshnessNow;
|
|
72
|
+
handleFreshnessNow = now;
|
|
73
|
+
return () => {
|
|
74
|
+
handleFreshnessNow = previous;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const WORKSPACE_SENTINEL_RELATIVE_PATH = ".paperclip-runtime/reusable-sandbox-lease.json";
|
|
78
|
+
// Quota-safety defaults (minutes). Daytona counts *stopped* sandboxes against
|
|
79
|
+
// the storage quota; only *archived* sandboxes move to cold object storage and
|
|
80
|
+
// stop counting. Without these, stopped/leaked sandboxes accumulate until the
|
|
81
|
+
// org quota fills. We apply sane defaults so every sandbox eventually leaves the
|
|
82
|
+
// quota on its own even when our own cleanup fails or never runs (crashed runs,
|
|
83
|
+
// failed lease destroys, orphaned probes). All three stay overridable per
|
|
84
|
+
// environment; an explicit 0/-1 in config is preserved.
|
|
85
|
+
//
|
|
86
|
+
// - autoStop: stop idle *running* sandboxes (frees CPU/RAM, starts the archive clock).
|
|
87
|
+
// - autoArchive: archive *stopped* sandboxes so they leave the disk quota.
|
|
88
|
+
// - autoDelete: backstop reaper for sandboxes nobody resumes.
|
|
89
|
+
const DEFAULT_AUTO_STOP_INTERVAL_MINUTES = 15;
|
|
90
|
+
const DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES = 60;
|
|
91
|
+
const DEFAULT_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60; // 7 days
|
|
92
|
+
// Sandboxes released with `archiveOnRelease` (test/probe runs) are archived so
|
|
93
|
+
// operators can inspect them from the Daytona dashboard, then expired by
|
|
94
|
+
// Daytona itself after this interval (counted from the stop that precedes the
|
|
95
|
+
// archive) so debugging copies don't accumulate.
|
|
96
|
+
const ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES = 60;
|
|
97
|
+
// Fail-fast cap for git network operations (push, fetch, pull, ls-remote, etc.)
|
|
98
|
+
// so a stalled remote or missing credential never consumes the full 900 s adapter
|
|
99
|
+
// RPC ceiling; callers always see an actionable error within this window.
|
|
100
|
+
const GIT_NETWORK_TIMEOUT_MS = 120_000;
|
|
101
|
+
// Per-call bound on the provider liveness read (`sandbox.refreshData()`). The
|
|
102
|
+
// Daytona SDK gives this metadata read no timeout, so a silently unresponsive
|
|
103
|
+
// sandbox connection leaves it pending with no error. The plugin then stalls
|
|
104
|
+
// until the outer host-to-worker RPC backstop fires, which is a general ceiling,
|
|
105
|
+
// not a fast, specific detector. This bound turns that silent hang into a fast,
|
|
106
|
+
// clear error. It is configurable through `livenessTimeoutMs`; a value of 0 or
|
|
107
|
+
// less disables the extra bound.
|
|
108
|
+
const DEFAULT_LIVENESS_TIMEOUT_MS = 30_000;
|
|
109
|
+
// Extra margin added to the SDK start/recover timeout when the plugin wraps
|
|
110
|
+
// those lifecycle calls in its own per-call bound. The SDK call already carries
|
|
111
|
+
// a `timeoutSeconds` deadline; the wrapper is a backstop for a connection-level
|
|
112
|
+
// hang that the SDK deadline can miss. The margin lets the SDK deadline fire
|
|
113
|
+
// first on a normal slow start, so the wrapper only fires on a true hang.
|
|
114
|
+
const LIVENESS_START_TIMEOUT_MARGIN_MS = 5_000;
|
|
115
|
+
// Noninteractive git credential defaults injected into every Daytona one-shot
|
|
116
|
+
// command so that git operations never stall waiting for a terminal prompt.
|
|
117
|
+
// Callers can override any of these via the env parameter.
|
|
118
|
+
const NONINTERACTIVE_GIT_ENV = {
|
|
119
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
120
|
+
GCM_INTERACTIVE: "Never",
|
|
121
|
+
GIT_ASKPASS: "echo",
|
|
122
|
+
SSH_ASKPASS: "echo",
|
|
123
|
+
SSH_ASKPASS_REQUIRE: "force",
|
|
124
|
+
};
|
|
125
|
+
const DEFAULT_SSH_ACCESS_MINUTES = 60;
|
|
126
|
+
const DAYTONA_SSH_GATEWAY_HOST = "ssh.app.daytona.io";
|
|
127
|
+
function parseOptionalString(value) {
|
|
128
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
129
|
+
}
|
|
130
|
+
function parseOptionalInteger(value) {
|
|
131
|
+
if (value == null || value === "")
|
|
132
|
+
return null;
|
|
133
|
+
const parsed = Number(value);
|
|
134
|
+
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
|
|
135
|
+
}
|
|
136
|
+
function parseOptionalNumber(value) {
|
|
137
|
+
if (value == null || value === "")
|
|
138
|
+
return null;
|
|
139
|
+
const parsed = Number(value);
|
|
140
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
141
|
+
}
|
|
142
|
+
function parseDriverConfig(raw) {
|
|
143
|
+
const timeoutMs = Number(raw.timeoutMs ?? 300_000);
|
|
144
|
+
const livenessTimeoutMs = Number(raw.livenessTimeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
|
|
145
|
+
return {
|
|
146
|
+
apiKey: parseOptionalString(raw.apiKey),
|
|
147
|
+
apiUrl: parseOptionalString(raw.apiUrl),
|
|
148
|
+
target: parseOptionalString(raw.target),
|
|
149
|
+
snapshot: parseOptionalString(raw.snapshot),
|
|
150
|
+
image: parseOptionalString(raw.image),
|
|
151
|
+
language: parseOptionalString(raw.language),
|
|
152
|
+
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 300_000,
|
|
153
|
+
livenessTimeoutMs: Number.isFinite(livenessTimeoutMs) ? Math.trunc(livenessTimeoutMs) : DEFAULT_LIVENESS_TIMEOUT_MS,
|
|
154
|
+
cpu: parseOptionalNumber(raw.cpu),
|
|
155
|
+
memory: parseOptionalNumber(raw.memory),
|
|
156
|
+
disk: parseOptionalNumber(raw.disk),
|
|
157
|
+
gpu: parseOptionalNumber(raw.gpu),
|
|
158
|
+
autoStopInterval: parseOptionalInteger(raw.autoStopInterval) ?? DEFAULT_AUTO_STOP_INTERVAL_MINUTES,
|
|
159
|
+
autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval) ?? DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES,
|
|
160
|
+
autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval) ?? DEFAULT_AUTO_DELETE_INTERVAL_MINUTES,
|
|
161
|
+
reuseLease: raw.reuseLease === true,
|
|
162
|
+
archiveOnRelease: raw.archiveOnRelease === true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function resolveApiKey(config) {
|
|
166
|
+
if (config.apiKey) {
|
|
167
|
+
return config.apiKey;
|
|
168
|
+
}
|
|
169
|
+
const envApiKey = process.env.DAYTONA_API_KEY?.trim() ?? "";
|
|
170
|
+
if (!envApiKey) {
|
|
171
|
+
throw new Error("Daytona sandbox environments require an API key in config or DAYTONA_API_KEY.");
|
|
172
|
+
}
|
|
173
|
+
return envApiKey;
|
|
174
|
+
}
|
|
175
|
+
function createDaytonaClient(config) {
|
|
176
|
+
const clientConfig = {
|
|
177
|
+
apiKey: resolveApiKey(config),
|
|
178
|
+
};
|
|
179
|
+
if (config.apiUrl)
|
|
180
|
+
clientConfig.apiUrl = config.apiUrl;
|
|
181
|
+
if (config.target)
|
|
182
|
+
clientConfig.target = config.target;
|
|
183
|
+
return new Daytona(clientConfig);
|
|
184
|
+
}
|
|
185
|
+
function buildResources(config) {
|
|
186
|
+
if (config.cpu == null && config.memory == null && config.disk == null && config.gpu == null) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
cpu: config.cpu ?? undefined,
|
|
191
|
+
memory: config.memory ?? undefined,
|
|
192
|
+
disk: config.disk ?? undefined,
|
|
193
|
+
gpu: config.gpu ?? undefined,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function buildCreateParams(config, labels) {
|
|
197
|
+
const base = {
|
|
198
|
+
labels,
|
|
199
|
+
language: config.language ?? undefined,
|
|
200
|
+
autoStopInterval: config.autoStopInterval ?? undefined,
|
|
201
|
+
autoArchiveInterval: config.autoArchiveInterval ?? undefined,
|
|
202
|
+
autoDeleteInterval: config.autoDeleteInterval ?? undefined,
|
|
203
|
+
};
|
|
204
|
+
if (config.image) {
|
|
205
|
+
return {
|
|
206
|
+
...base,
|
|
207
|
+
image: config.image,
|
|
208
|
+
resources: buildResources(config),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
...base,
|
|
213
|
+
snapshot: config.snapshot ?? undefined,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function hasResourceRequest(config) {
|
|
217
|
+
return config.cpu != null || config.memory != null || config.disk != null || config.gpu != null;
|
|
218
|
+
}
|
|
219
|
+
function validateResourceRequest(config) {
|
|
220
|
+
if (!hasResourceRequest(config) || config.image)
|
|
221
|
+
return null;
|
|
222
|
+
return "Daytona resource settings require image-backed sandbox creation; snapshot/default sandbox creation cannot override CPU, memory, disk, or GPU.";
|
|
223
|
+
}
|
|
224
|
+
function validateRuntimeResourceRequest(config) {
|
|
225
|
+
// A snapshot bakes in its own resource allocation, so resources are dropped at
|
|
226
|
+
// create time (see buildCreateParams) rather than failing the run when a custom
|
|
227
|
+
// image snapshot is layered over a base config that carries CPU/memory/disk/GPU.
|
|
228
|
+
if (!hasResourceRequest(config) || config.image || config.snapshot)
|
|
229
|
+
return null;
|
|
230
|
+
return "Daytona resource settings require image-backed sandbox creation; default sandbox creation cannot override CPU, memory, disk, or GPU.";
|
|
231
|
+
}
|
|
232
|
+
function buildSandboxLabels(input) {
|
|
233
|
+
return {
|
|
234
|
+
"paperclip-provider": "daytona",
|
|
235
|
+
"paperclip-company-id": input.companyId,
|
|
236
|
+
"paperclip-environment-id": input.environmentId,
|
|
237
|
+
"paperclip-reuse-lease": input.reuseLease ? "true" : "false",
|
|
238
|
+
...(input.runId ? { "paperclip-run-id": input.runId } : {}),
|
|
239
|
+
...(input.setupSessionId ? { "paperclip-setup-session-id": input.setupSessionId } : {}),
|
|
240
|
+
...(input.purpose ? { "paperclip-purpose": input.purpose } : {}),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function toTimeoutSeconds(timeoutMs) {
|
|
244
|
+
return Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
245
|
+
}
|
|
246
|
+
function resolveTimeoutMs(paramsTimeoutMs, config) {
|
|
247
|
+
return paramsTimeoutMs != null && Number.isFinite(paramsTimeoutMs) && paramsTimeoutMs > 0
|
|
248
|
+
? Math.trunc(paramsTimeoutMs)
|
|
249
|
+
: config.timeoutMs;
|
|
250
|
+
}
|
|
251
|
+
function formatErrorMessage(error) {
|
|
252
|
+
return error instanceof Error ? error.message : String(error);
|
|
253
|
+
}
|
|
254
|
+
function isRecord(value) {
|
|
255
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
256
|
+
}
|
|
257
|
+
function stableStringify(value) {
|
|
258
|
+
if (Array.isArray(value)) {
|
|
259
|
+
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
260
|
+
}
|
|
261
|
+
if (isRecord(value)) {
|
|
262
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
263
|
+
}
|
|
264
|
+
return JSON.stringify(value) ?? "null";
|
|
265
|
+
}
|
|
266
|
+
function isValidUrl(value) {
|
|
267
|
+
try {
|
|
268
|
+
new URL(value);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// A per-call liveness bound elapsed before the wrapped provider call returned.
|
|
276
|
+
// The message names the operation and the bound so an operator sees at once
|
|
277
|
+
// that the sandbox connection is unresponsive, not that the operation is slow.
|
|
278
|
+
class SandboxLivenessTimeoutError extends Error {
|
|
279
|
+
constructor(operation, timeoutMs) {
|
|
280
|
+
super(`Daytona sandbox liveness call "${operation}" did not respond within ${timeoutMs} ms; `
|
|
281
|
+
+ "the sandbox connection is unresponsive.");
|
|
282
|
+
this.name = "SandboxLivenessTimeoutError";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Race a provider call against a per-call deadline. A value of 0 or less turns
|
|
286
|
+
// the bound off and runs the call unwrapped. The timer is always cleared, so a
|
|
287
|
+
// call that resolves before the deadline leaks no pending timer. A call that
|
|
288
|
+
// never resolves stays pending after the deadline rejects, but it holds no
|
|
289
|
+
// timer and produces no unhandled rejection.
|
|
290
|
+
async function withLivenessTimeout(operation, timeoutMs, run) {
|
|
291
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
292
|
+
return run();
|
|
293
|
+
}
|
|
294
|
+
let timer;
|
|
295
|
+
const deadline = new Promise((_, reject) => {
|
|
296
|
+
timer = setTimeout(() => reject(new SandboxLivenessTimeoutError(operation, timeoutMs)), timeoutMs);
|
|
297
|
+
});
|
|
298
|
+
try {
|
|
299
|
+
return await Promise.race([run(), deadline]);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
if (timer !== undefined)
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** A dead bridge must not prevent provider-level termination. Stop/delete is
|
|
307
|
+
* the receipt boundary; timing out this drain is never termination evidence. */
|
|
308
|
+
async function drainSandboxBeforeTermination(sandbox, scope) {
|
|
309
|
+
const timeoutMs = Math.max(1, Math.min(scope.config.livenessTimeoutMs || 10_000, 10_000));
|
|
310
|
+
const steps = [
|
|
311
|
+
["sandbox.activityDrain", () => sandboxHandleActivityGates.waitForIdle(scope)],
|
|
312
|
+
["sandbox.sessionTeardown", () => teardownSession(sandbox, scope)],
|
|
313
|
+
["sandbox.channelTeardown", () => closeDaytonaDuplexChannelsForLease(scope.providerLeaseId)],
|
|
314
|
+
];
|
|
315
|
+
for (const [operation, action] of steps) {
|
|
316
|
+
try {
|
|
317
|
+
await withLivenessTimeout(operation, timeoutMs, action);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// Each cleanup is independent; one hung bridge must not retain other routes.
|
|
321
|
+
console.warn("Sandbox bridge cleanup failed; continuing provider termination.");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function terminateAtProvider(scope, operation, action) {
|
|
326
|
+
const timeoutMs = scope.config.timeoutMs > 0 ? scope.config.timeoutMs : 300_000;
|
|
327
|
+
return withLivenessTimeout(operation, timeoutMs + LIVENESS_START_TIMEOUT_MARGIN_MS, action);
|
|
328
|
+
}
|
|
329
|
+
async function ensureSandboxStarted(sandbox, timeoutSeconds) {
|
|
330
|
+
if (sandbox.state === "started")
|
|
331
|
+
return;
|
|
332
|
+
// Bound the lifecycle call just past its own SDK deadline. A normal slow start
|
|
333
|
+
// finishes within `timeoutSeconds`; only a connection-level hang the SDK
|
|
334
|
+
// deadline misses reaches this wrapper bound.
|
|
335
|
+
const startBoundMs = timeoutSeconds * 1_000 + LIVENESS_START_TIMEOUT_MARGIN_MS;
|
|
336
|
+
if (sandbox.state === "error") {
|
|
337
|
+
if (sandbox.recoverable) {
|
|
338
|
+
await withLivenessTimeout("sandbox.recover", startBoundMs, () => sandbox.recover(timeoutSeconds));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
throw new Error(`Daytona sandbox ${sandbox.id} is in an unrecoverable error state: ${sandbox.errorReason ?? "unknown error"}`);
|
|
342
|
+
}
|
|
343
|
+
await withLivenessTimeout("sandbox.start", startBoundMs, () => sandbox.start(timeoutSeconds));
|
|
344
|
+
}
|
|
345
|
+
function hasMissingSandboxContainer(sandbox) {
|
|
346
|
+
if (sandbox.state !== "error" || sandbox.recoverable !== false || typeof sandbox.errorReason !== "string")
|
|
347
|
+
return false;
|
|
348
|
+
if (typeof sandbox.id !== "string" || !sandbox.id)
|
|
349
|
+
return false;
|
|
350
|
+
return sandbox.errorReason ===
|
|
351
|
+
`not found: failed to inspect sandbox container ${sandbox.id}: Error response from daemon: No such container: ${sandbox.id}`;
|
|
352
|
+
}
|
|
353
|
+
async function resolveSandboxWorkingDirectory(sandbox) {
|
|
354
|
+
const root = (await sandbox.getWorkDir())?.trim()
|
|
355
|
+
|| (await sandbox.getUserHomeDir())?.trim()
|
|
356
|
+
|| "/home/daytona";
|
|
357
|
+
const remoteCwd = path.posix.join(root, "paperclip-workspace");
|
|
358
|
+
await sandbox.fs.createFolder(remoteCwd, "755");
|
|
359
|
+
return remoteCwd;
|
|
360
|
+
}
|
|
361
|
+
async function detectSandboxShellCommand(sandbox, timeoutSeconds) {
|
|
362
|
+
try {
|
|
363
|
+
const result = await sandbox.process.executeCommand("if command -v bash >/dev/null 2>&1; then printf bash; else printf sh; fi", undefined, undefined, timeoutSeconds);
|
|
364
|
+
return result.result?.trim() === "bash" ? "bash" : "sh";
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return "sh";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function parseProbeInteger(value) {
|
|
371
|
+
const trimmed = value?.trim() ?? "";
|
|
372
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
376
|
+
return Number.isInteger(parsed) ? parsed : null;
|
|
377
|
+
}
|
|
378
|
+
function workspaceSentinelToken(input) {
|
|
379
|
+
if (!input.config.reuseLease || !input.params.agentId || (!input.params.executionWorkspaceId && !input.params.issueId)) {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
return createHash("sha256")
|
|
383
|
+
.update(stableStringify({
|
|
384
|
+
provider: "daytona",
|
|
385
|
+
companyId: input.params.companyId,
|
|
386
|
+
environmentId: input.params.environmentId,
|
|
387
|
+
agentId: input.params.agentId,
|
|
388
|
+
executionWorkspaceId: input.params.executionWorkspaceId,
|
|
389
|
+
...(input.params.executionWorkspaceId ? {} : { projectlessIssueId: input.params.issueId }),
|
|
390
|
+
adapterType: input.params.adapterType ?? null,
|
|
391
|
+
image: input.config.image,
|
|
392
|
+
snapshot: input.config.snapshot,
|
|
393
|
+
target: input.config.target,
|
|
394
|
+
// Include resource-shaping inputs so changing the requested allocation
|
|
395
|
+
// expires old reusable leases and forces a fresh sandbox instead of
|
|
396
|
+
// reusing a previously provisioned (e.g. one-CPU) sandbox.
|
|
397
|
+
cpu: input.config.cpu,
|
|
398
|
+
memory: input.config.memory,
|
|
399
|
+
disk: input.config.disk,
|
|
400
|
+
gpu: input.config.gpu,
|
|
401
|
+
}))
|
|
402
|
+
.digest("hex");
|
|
403
|
+
}
|
|
404
|
+
function workspaceSentinelPath(remoteCwd) {
|
|
405
|
+
return path.posix.join(remoteCwd, WORKSPACE_SENTINEL_RELATIVE_PATH);
|
|
406
|
+
}
|
|
407
|
+
async function writeWorkspaceSentinel(input) {
|
|
408
|
+
const sentinelPath = workspaceSentinelPath(input.remoteCwd);
|
|
409
|
+
const token = workspaceSentinelToken({ params: input.params, config: input.config });
|
|
410
|
+
if (!token) {
|
|
411
|
+
return { path: sentinelPath, token: null, result: "skipped" };
|
|
412
|
+
}
|
|
413
|
+
await input.sandbox.fs.createFolder(path.posix.dirname(sentinelPath), "755");
|
|
414
|
+
await input.sandbox.fs.uploadFile(Buffer.from(JSON.stringify({
|
|
415
|
+
version: 1,
|
|
416
|
+
token,
|
|
417
|
+
companyId: input.params.companyId,
|
|
418
|
+
environmentId: input.params.environmentId,
|
|
419
|
+
agentId: input.params.agentId,
|
|
420
|
+
executionWorkspaceId: input.params.executionWorkspaceId,
|
|
421
|
+
...(input.params.executionWorkspaceId ? {} : { projectlessIssueId: input.params.issueId }),
|
|
422
|
+
adapterType: input.params.adapterType ?? null,
|
|
423
|
+
provider: "daytona",
|
|
424
|
+
writtenAt: new Date().toISOString(),
|
|
425
|
+
}, null, 2), "utf8"), sentinelPath, input.timeoutSeconds);
|
|
426
|
+
return { path: sentinelPath, token, result: "written" };
|
|
427
|
+
}
|
|
428
|
+
async function verifyWorkspaceSentinel(input) {
|
|
429
|
+
const metadataSentinel = isRecord(input.leaseMetadata?.workspaceSentinel)
|
|
430
|
+
? input.leaseMetadata.workspaceSentinel
|
|
431
|
+
: null;
|
|
432
|
+
const sentinelPath = typeof metadataSentinel?.path === "string"
|
|
433
|
+
? metadataSentinel.path
|
|
434
|
+
: workspaceSentinelPath(input.remoteCwd);
|
|
435
|
+
const expectedToken = typeof metadataSentinel?.token === "string" ? metadataSentinel.token : null;
|
|
436
|
+
if (!expectedToken) {
|
|
437
|
+
return { path: sentinelPath, token: null, result: "missing" };
|
|
438
|
+
}
|
|
439
|
+
const result = await input.sandbox.process.executeCommand(`cat ${shellQuote(sentinelPath)}`, undefined, undefined, input.timeoutSeconds);
|
|
440
|
+
if (result.exitCode !== 0) {
|
|
441
|
+
return { path: sentinelPath, token: expectedToken, result: "missing" };
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const parsed = JSON.parse(result.result ?? result.artifacts?.stdout ?? "");
|
|
445
|
+
const actualToken = isRecord(parsed) && typeof parsed.token === "string" ? parsed.token : null;
|
|
446
|
+
return {
|
|
447
|
+
path: sentinelPath,
|
|
448
|
+
token: expectedToken,
|
|
449
|
+
result: actualToken === expectedToken ? "matched" : "mismatch",
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return { path: sentinelPath, token: expectedToken, result: "mismatch" };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function leaseMetadata(input) {
|
|
457
|
+
return {
|
|
458
|
+
provider: "daytona",
|
|
459
|
+
shellCommand: input.shellCommand,
|
|
460
|
+
sandboxId: input.sandbox.id,
|
|
461
|
+
sandboxName: input.sandbox.name,
|
|
462
|
+
sandboxState: input.sandboxState ?? input.sandbox.state ?? null,
|
|
463
|
+
image: input.config.image,
|
|
464
|
+
snapshot: input.config.snapshot,
|
|
465
|
+
target: input.sandbox.target,
|
|
466
|
+
timeoutMs: input.config.timeoutMs,
|
|
467
|
+
reuseLease: input.config.reuseLease,
|
|
468
|
+
// Persisted so the release path (which rebuilds config from lease
|
|
469
|
+
// metadata) still knows to archive instead of delete.
|
|
470
|
+
...(input.config.archiveOnRelease ? { archiveOnRelease: true } : {}),
|
|
471
|
+
remoteCwd: input.remoteCwd,
|
|
472
|
+
resumedLease: input.resumedLease,
|
|
473
|
+
...(input.resumedLease
|
|
474
|
+
? { resumedFromState: input.resumedFromState ?? null }
|
|
475
|
+
: {}),
|
|
476
|
+
// Record the resources Paperclip attempted to request so future diagnosis
|
|
477
|
+
// can compare requested allocation against what Daytona provisioned.
|
|
478
|
+
...(input.config.cpu != null ? { cpu: input.config.cpu } : {}),
|
|
479
|
+
...(input.config.memory != null ? { memory: input.config.memory } : {}),
|
|
480
|
+
...(input.config.disk != null ? { disk: input.config.disk } : {}),
|
|
481
|
+
...(input.config.gpu != null ? { gpu: input.config.gpu } : {}),
|
|
482
|
+
...(input.workspaceSentinel ? { workspaceSentinel: input.workspaceSentinel } : {}),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function shellQuote(value) {
|
|
486
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
487
|
+
}
|
|
488
|
+
function resolveConnectionExpiresInMinutes(value) {
|
|
489
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
490
|
+
return DEFAULT_SSH_ACCESS_MINUTES;
|
|
491
|
+
return Math.min(24 * 60, Math.max(1, Math.trunc(value)));
|
|
492
|
+
}
|
|
493
|
+
function expiresAtForMinutes(minutes) {
|
|
494
|
+
return new Date(Date.now() + minutes * 60_000).toISOString();
|
|
495
|
+
}
|
|
496
|
+
// Configure a provider-side time-to-live so Daytona destroys the sandbox at or
|
|
497
|
+
// before the caller-requested deadline, even after a Paperclip crash or outage.
|
|
498
|
+
// `setTtl` counts wall-clock time regardless of the sandbox state, so the destroy
|
|
499
|
+
// happens even when the sandbox is stopped, paused, or archived. The function
|
|
500
|
+
// returns the real provider destroy time (`autoDestroyAt`) as evidence of the
|
|
501
|
+
// provider-side bound. It returns null when the caller sets no deadline, when the
|
|
502
|
+
// deadline is invalid, or when the deadline is less than one minute away (Daytona
|
|
503
|
+
// TTL granularity is one minute, so a nearer deadline maps to no valid TTL). The
|
|
504
|
+
// server then fails closed on a null expiry and releases the lease.
|
|
505
|
+
async function configureSandboxExpiry(input) {
|
|
506
|
+
const requestedMs = input.requestedExpiresAt ? Date.parse(input.requestedExpiresAt) : Number.NaN;
|
|
507
|
+
if (!Number.isFinite(requestedMs))
|
|
508
|
+
return null;
|
|
509
|
+
// Round DOWN so the provider destroy time never lands after the deadline.
|
|
510
|
+
const ttlMinutes = Math.floor((requestedMs - input.nowMs) / 60_000);
|
|
511
|
+
if (ttlMinutes < 1)
|
|
512
|
+
return null;
|
|
513
|
+
await input.sandbox.setTtl(ttlMinutes);
|
|
514
|
+
await input.sandbox.refreshData();
|
|
515
|
+
const autoDestroyAt = input.sandbox.autoDestroyAt;
|
|
516
|
+
return typeof autoDestroyAt === "string" && autoDestroyAt.trim().length > 0
|
|
517
|
+
? autoDestroyAt.trim()
|
|
518
|
+
: null;
|
|
519
|
+
}
|
|
520
|
+
function sanitizeSnapshotName(value, fallback) {
|
|
521
|
+
const cleaned = (value ?? fallback)
|
|
522
|
+
.trim()
|
|
523
|
+
.toLowerCase()
|
|
524
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
525
|
+
.replace(/^-+|-+$/g, "")
|
|
526
|
+
.slice(0, 96);
|
|
527
|
+
return cleaned || fallback;
|
|
528
|
+
}
|
|
529
|
+
function withSetupSourceTemplate(config, params) {
|
|
530
|
+
if (!params.sourceTemplateRef)
|
|
531
|
+
return config;
|
|
532
|
+
const sourceKind = params.sourceTemplateKind ?? "snapshot";
|
|
533
|
+
if (sourceKind === "image") {
|
|
534
|
+
return {
|
|
535
|
+
...config,
|
|
536
|
+
image: params.sourceTemplateRef,
|
|
537
|
+
snapshot: null,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (sourceKind !== "snapshot") {
|
|
541
|
+
throw new Error(`Daytona interactive setup can start from image or snapshot templates only, not ${sourceKind}.`);
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
...config,
|
|
545
|
+
snapshot: params.sourceTemplateRef,
|
|
546
|
+
image: null,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
async function createSshConnection(sandbox, expiresInMinutes) {
|
|
550
|
+
const createSshAccess = sandbox.createSshAccess;
|
|
551
|
+
if (typeof createSshAccess !== "function") {
|
|
552
|
+
throw new Error("Daytona interactive setup requires @daytonaio/sdk Sandbox.createSshAccess support.");
|
|
553
|
+
}
|
|
554
|
+
const fallbackExpiresAt = expiresAtForMinutes(expiresInMinutes);
|
|
555
|
+
const access = await createSshAccess.call(sandbox, expiresInMinutes);
|
|
556
|
+
const token = typeof access.token === "string" && access.token.trim().length > 0
|
|
557
|
+
? access.token.trim()
|
|
558
|
+
: null;
|
|
559
|
+
const commandFromAccess = typeof access.command === "string" && access.command.trim().length > 0
|
|
560
|
+
? access.command.trim()
|
|
561
|
+
: typeof access.sshCommand === "string" && access.sshCommand.trim().length > 0
|
|
562
|
+
? access.sshCommand.trim()
|
|
563
|
+
: null;
|
|
564
|
+
const command = commandFromAccess ?? (token ? `ssh ${token}@${DAYTONA_SSH_GATEWAY_HOST}` : null);
|
|
565
|
+
if (!command) {
|
|
566
|
+
throw new Error("Daytona SSH access did not return a token or SSH command.");
|
|
567
|
+
}
|
|
568
|
+
const expiresAt = typeof access.expiresAt === "string" && access.expiresAt.trim().length > 0
|
|
569
|
+
? access.expiresAt.trim()
|
|
570
|
+
: fallbackExpiresAt;
|
|
571
|
+
return {
|
|
572
|
+
connectionSummary: {
|
|
573
|
+
type: "ssh",
|
|
574
|
+
username: "token",
|
|
575
|
+
hostRedacted: true,
|
|
576
|
+
portRedacted: true,
|
|
577
|
+
commandRedacted: true,
|
|
578
|
+
expiresAt,
|
|
579
|
+
metadata: {
|
|
580
|
+
provider: "daytona",
|
|
581
|
+
expiresInMinutes,
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
connectionPayload: {
|
|
585
|
+
type: "ssh",
|
|
586
|
+
command,
|
|
587
|
+
token,
|
|
588
|
+
expiresAt,
|
|
589
|
+
metadata: {
|
|
590
|
+
provider: "daytona",
|
|
591
|
+
sensitive: true,
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
function interactiveSetupMetadata(input) {
|
|
597
|
+
return {
|
|
598
|
+
provider: "daytona",
|
|
599
|
+
sandboxId: input.sandbox.id,
|
|
600
|
+
sandboxState: input.sandbox.state ?? null,
|
|
601
|
+
shellCommand: input.shellCommand,
|
|
602
|
+
imageConfigured: Boolean(input.config.image),
|
|
603
|
+
snapshotConfigured: Boolean(input.config.snapshot),
|
|
604
|
+
sourceTemplateRefRedacted: Boolean(input.sourceTemplateRef),
|
|
605
|
+
target: input.sandbox.target,
|
|
606
|
+
timeoutMs: input.config.timeoutMs,
|
|
607
|
+
remoteCwd: input.remoteCwd,
|
|
608
|
+
connectionRedacted: true,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
function isValidShellEnvKey(value) {
|
|
612
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
613
|
+
}
|
|
614
|
+
const GIT_NETWORK_SUBCOMMANDS = new Set(["push", "fetch", "pull", "ls-remote", "clone"]);
|
|
615
|
+
function isGitNetworkCommand(command, args) {
|
|
616
|
+
if (path.basename(command) !== "git")
|
|
617
|
+
return false;
|
|
618
|
+
// Find the first positional arg (the git subcommand), skipping flags and their values.
|
|
619
|
+
let i = 0;
|
|
620
|
+
while (i < args.length) {
|
|
621
|
+
const arg = args[i];
|
|
622
|
+
if (arg === "-C" || arg === "-c" || arg === "--git-dir" || arg === "--work-tree") {
|
|
623
|
+
i += 2;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (arg.startsWith("-")) {
|
|
627
|
+
i++;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (GIT_NETWORK_SUBCOMMANDS.has(arg))
|
|
631
|
+
return true;
|
|
632
|
+
if (arg === "remote") {
|
|
633
|
+
const next = args.slice(i + 1).find(a => !a.startsWith("-"));
|
|
634
|
+
return next === "update";
|
|
635
|
+
}
|
|
636
|
+
if (arg === "submodule") {
|
|
637
|
+
const next = args.slice(i + 1).find(a => !a.startsWith("-"));
|
|
638
|
+
return next === "update";
|
|
639
|
+
}
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
// Build the one-shot exec command. Daytona's `executeCommand` runs the script
|
|
645
|
+
// in a non-login shell, so it does not source `/etc/profile` on its own. The
|
|
646
|
+
// Daytona reference image puts `node`, `claude`, and the other CLIs on the PATH
|
|
647
|
+
// through `/etc/profile.d/00-restore-env.sh`, which only `/etc/profile` sources.
|
|
648
|
+
// So the wrapper sources the login profiles itself; a non-login shell is then
|
|
649
|
+
// enough to resolve the CLIs. The wrapper no longer sources `nvm.sh`; the
|
|
650
|
+
// sandbox image supplies `node` on the PATH. See the sandbox runtime
|
|
651
|
+
// requirements document.
|
|
652
|
+
function buildLoginShellScript(input) {
|
|
653
|
+
const callerEnv = input.env ?? {};
|
|
654
|
+
for (const key of Object.keys(callerEnv)) {
|
|
655
|
+
if (!isValidShellEnvKey(key)) {
|
|
656
|
+
throw new Error(`Invalid sandbox environment variable key: ${key}`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Caller env takes priority over noninteractive git credential defaults
|
|
660
|
+
const env = { ...NONINTERACTIVE_GIT_ENV, ...callerEnv };
|
|
661
|
+
const envArgs = Object.entries(env)
|
|
662
|
+
.filter((entry) => typeof entry[1] === "string")
|
|
663
|
+
.map(([key, value]) => `${key}=${shellQuote(value)}`);
|
|
664
|
+
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
|
|
665
|
+
const redirectedCommand = input.stdinPath
|
|
666
|
+
? `${commandParts} < ${shellQuote(input.stdinPath)}`
|
|
667
|
+
: commandParts;
|
|
668
|
+
// Each `executeCommand` call runs in its own shell, so we don't `exec`-
|
|
669
|
+
// replace it; running the command as the last `&&`-chained line is enough to
|
|
670
|
+
// surface the right exit code.
|
|
671
|
+
const finalLine = envArgs.length > 0
|
|
672
|
+
? `env ${envArgs.join(" ")} ${redirectedCommand}`
|
|
673
|
+
: redirectedCommand;
|
|
674
|
+
const lines = [
|
|
675
|
+
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
|
|
676
|
+
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
|
|
677
|
+
// .bash_profile typically sources .bashrc itself; only source .bashrc
|
|
678
|
+
// directly when no .bash_profile exists to avoid double-running setup.
|
|
679
|
+
'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',
|
|
680
|
+
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
|
|
681
|
+
];
|
|
682
|
+
if (input.cwd) {
|
|
683
|
+
lines.push(`cd ${shellQuote(input.cwd)}`);
|
|
684
|
+
}
|
|
685
|
+
lines.push(finalLine);
|
|
686
|
+
return lines.join(" && ");
|
|
687
|
+
}
|
|
688
|
+
// The workspace remote dir is the confinement root for native file sync. It is
|
|
689
|
+
// recorded on the lease metadata at acquire/resume time; require it so a sync can
|
|
690
|
+
// never run without a concrete root to confine every sandbox path against.
|
|
691
|
+
function resolveSyncRemoteDir(lease) {
|
|
692
|
+
const remoteCwd = lease.metadata?.remoteCwd;
|
|
693
|
+
if (typeof remoteCwd === "string" && remoteCwd.trim().length > 0) {
|
|
694
|
+
return remoteCwd.trim();
|
|
695
|
+
}
|
|
696
|
+
throw new Error("Daytona file sync requires a workspace remote dir on the lease metadata.");
|
|
697
|
+
}
|
|
698
|
+
async function createSandbox(params, config, options = {}) {
|
|
699
|
+
const resourceRequestError = validateRuntimeResourceRequest(config);
|
|
700
|
+
if (resourceRequestError) {
|
|
701
|
+
throw new Error(resourceRequestError);
|
|
702
|
+
}
|
|
703
|
+
const client = createDaytonaClient(config);
|
|
704
|
+
const createParams = buildCreateParams(config, buildSandboxLabels({
|
|
705
|
+
companyId: params.companyId,
|
|
706
|
+
environmentId: params.environmentId,
|
|
707
|
+
runId: "runId" in params ? params.runId : undefined,
|
|
708
|
+
setupSessionId: "sessionId" in params ? params.sessionId : undefined,
|
|
709
|
+
purpose: options.purpose,
|
|
710
|
+
reuseLease: config.reuseLease,
|
|
711
|
+
}));
|
|
712
|
+
const sandbox = await client.create(createParams, {
|
|
713
|
+
timeout: toTimeoutSeconds(config.timeoutMs),
|
|
714
|
+
});
|
|
715
|
+
return sandbox;
|
|
716
|
+
}
|
|
717
|
+
// Non-secret provider/account fingerprint. Uses the *resolved* key (config or
|
|
718
|
+
// DAYTONA_API_KEY env fallback) so an env-provided credential is still scoped,
|
|
719
|
+
// but only its sha256 digest — never the key itself — enters the cache key (C1/C6).
|
|
720
|
+
function sandboxAccountDiscriminator(config) {
|
|
721
|
+
const resolvedApiKey = config.apiKey ?? process.env.DAYTONA_API_KEY?.trim() ?? null;
|
|
722
|
+
return createHash("sha256")
|
|
723
|
+
.update(stableStringify({
|
|
724
|
+
apiUrl: config.apiUrl,
|
|
725
|
+
// Target is a creation placement hint, not account identity: the SDK
|
|
726
|
+
// resolves existing sandboxes by ID. Lease metadata fills an omitted
|
|
727
|
+
// target with the actual region, which must not split admission state.
|
|
728
|
+
apiKey: resolvedApiKey,
|
|
729
|
+
}))
|
|
730
|
+
.digest("hex");
|
|
731
|
+
}
|
|
732
|
+
function sandboxHandleCacheKey(scope) {
|
|
733
|
+
return stableStringify({
|
|
734
|
+
driverKey: scope.driverKey,
|
|
735
|
+
companyId: scope.companyId,
|
|
736
|
+
environmentId: scope.environmentId,
|
|
737
|
+
providerLeaseId: scope.providerLeaseId,
|
|
738
|
+
account: sandboxAccountDiscriminator(scope.config),
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
function assertHandleMatchesLease(sandbox, providerLeaseId) {
|
|
742
|
+
// C2: a handle must never stand in for a different sandbox than the lease
|
|
743
|
+
// asked for. Belt-and-suspenders against a provider that returns a renamed or
|
|
744
|
+
// substituted sandbox, and against any future key collision.
|
|
745
|
+
if (sandbox.id !== providerLeaseId) {
|
|
746
|
+
throw new Error(`Daytona sandbox handle mismatch: handle ${sandbox.id} does not belong to lease ${providerLeaseId}.`);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// A cached `Sandbox` carries the provider state captured when it was last
|
|
750
|
+
// fetched/refreshed. Daytona auto-stops an idle sandbox after `autoStopInterval`
|
|
751
|
+
// minutes, at which point that snapshot ("started") no longer matches reality
|
|
752
|
+
// and `ensureSandboxStarted` would wrongly skip the restart, sending every
|
|
753
|
+
// subsequent exec/sync at a stopped sandbox. Before reusing a handle that has
|
|
754
|
+
// gone untouched for this fraction of the auto-stop interval we re-read the live
|
|
755
|
+
// state so the restart decision is made against the truth. Reusing a handle for
|
|
756
|
+
// an operation resets Daytona's idle clock, so an actively-used lease stays well
|
|
757
|
+
// inside the window and never pays the refresh — only a lease resumed after an
|
|
758
|
+
// idle gap does.
|
|
759
|
+
const STALE_HANDLE_REFRESH_SAFETY_FRACTION = 0.5;
|
|
760
|
+
function staleHandleRefreshThresholdMs(autoStopIntervalMinutes) {
|
|
761
|
+
// Auto-stop disabled (0 / null): the provider never stops the sandbox out from
|
|
762
|
+
// under a live handle, so the started snapshot stays valid until we evict it
|
|
763
|
+
// and no refresh is warranted.
|
|
764
|
+
if (autoStopIntervalMinutes == null || autoStopIntervalMinutes <= 0)
|
|
765
|
+
return null;
|
|
766
|
+
return Math.floor(autoStopIntervalMinutes * 60_000 * STALE_HANDLE_REFRESH_SAFETY_FRACTION);
|
|
767
|
+
}
|
|
768
|
+
const sandboxHandleTeardownGates = (() => {
|
|
769
|
+
const gates = new Map();
|
|
770
|
+
function begin(scope) {
|
|
771
|
+
const key = sandboxHandleCacheKey(scope);
|
|
772
|
+
const existing = gates.get(key);
|
|
773
|
+
if (existing) {
|
|
774
|
+
existing.refCount += 1;
|
|
775
|
+
return existing;
|
|
776
|
+
}
|
|
777
|
+
let release;
|
|
778
|
+
const gate = {
|
|
779
|
+
promise: new Promise((resolve) => {
|
|
780
|
+
release = resolve;
|
|
781
|
+
}),
|
|
782
|
+
release: () => release(),
|
|
783
|
+
refCount: 1,
|
|
784
|
+
};
|
|
785
|
+
gates.set(key, gate);
|
|
786
|
+
return gate;
|
|
787
|
+
}
|
|
788
|
+
function current(scope) {
|
|
789
|
+
return gates.get(sandboxHandleCacheKey(scope)) ?? null;
|
|
790
|
+
}
|
|
791
|
+
function end(scope, gate) {
|
|
792
|
+
const key = sandboxHandleCacheKey(scope);
|
|
793
|
+
gate.refCount -= 1;
|
|
794
|
+
if (gate.refCount > 0)
|
|
795
|
+
return;
|
|
796
|
+
if (gates.get(key) === gate) {
|
|
797
|
+
gates.delete(key);
|
|
798
|
+
}
|
|
799
|
+
gate.release();
|
|
800
|
+
}
|
|
801
|
+
function reset() {
|
|
802
|
+
gates.clear();
|
|
803
|
+
}
|
|
804
|
+
return { begin, current, end, reset };
|
|
805
|
+
})();
|
|
806
|
+
const sandboxHandleActivityGates = (() => {
|
|
807
|
+
const gates = new Map();
|
|
808
|
+
async function begin(scope) {
|
|
809
|
+
const key = sandboxHandleCacheKey(scope);
|
|
810
|
+
const existing = gates.get(key);
|
|
811
|
+
if (existing) {
|
|
812
|
+
existing.refCount += 1;
|
|
813
|
+
return existing;
|
|
814
|
+
}
|
|
815
|
+
let release;
|
|
816
|
+
const gate = {
|
|
817
|
+
promise: new Promise((resolve) => {
|
|
818
|
+
release = resolve;
|
|
819
|
+
}),
|
|
820
|
+
release: () => release(),
|
|
821
|
+
refCount: 1,
|
|
822
|
+
};
|
|
823
|
+
gates.set(key, gate);
|
|
824
|
+
return gate;
|
|
825
|
+
}
|
|
826
|
+
async function waitForIdle(scope) {
|
|
827
|
+
const gate = gates.get(sandboxHandleCacheKey(scope));
|
|
828
|
+
if (!gate)
|
|
829
|
+
return;
|
|
830
|
+
await gate.promise;
|
|
831
|
+
}
|
|
832
|
+
function end(scope, gate) {
|
|
833
|
+
const key = sandboxHandleCacheKey(scope);
|
|
834
|
+
gate.refCount -= 1;
|
|
835
|
+
if (gate.refCount > 0)
|
|
836
|
+
return;
|
|
837
|
+
if (gates.get(key) === gate) {
|
|
838
|
+
gates.delete(key);
|
|
839
|
+
}
|
|
840
|
+
gate.release();
|
|
841
|
+
}
|
|
842
|
+
function reset() {
|
|
843
|
+
gates.clear();
|
|
844
|
+
}
|
|
845
|
+
function isActive(scope) {
|
|
846
|
+
return gates.has(sandboxHandleCacheKey(scope));
|
|
847
|
+
}
|
|
848
|
+
return { begin, waitForIdle, isActive, end, reset };
|
|
849
|
+
})();
|
|
850
|
+
const sandboxHandleLeaseAdmissionStates = (() => {
|
|
851
|
+
const states = new Map();
|
|
852
|
+
function key(scope) {
|
|
853
|
+
return sandboxHandleCacheKey(scope);
|
|
854
|
+
}
|
|
855
|
+
function open(scope) {
|
|
856
|
+
states.set(key(scope), false);
|
|
857
|
+
}
|
|
858
|
+
function close(scope) {
|
|
859
|
+
states.set(key(scope), true);
|
|
860
|
+
}
|
|
861
|
+
function isClosed(scope) {
|
|
862
|
+
return states.get(key(scope)) === true;
|
|
863
|
+
}
|
|
864
|
+
function reset() {
|
|
865
|
+
states.clear();
|
|
866
|
+
}
|
|
867
|
+
return { open, close, isClosed, reset };
|
|
868
|
+
})();
|
|
869
|
+
async function withSandboxActivityGate(scope, fn, options = {}) {
|
|
870
|
+
while (true) {
|
|
871
|
+
if (!options.allowClosed && sandboxHandleLeaseAdmissionStates.isClosed(scope)) {
|
|
872
|
+
throw new Error(`Daytona sandbox lease ${scope.providerLeaseId} is no longer active.`);
|
|
873
|
+
}
|
|
874
|
+
const teardownGate = sandboxHandleTeardownGates.current(scope);
|
|
875
|
+
if (teardownGate) {
|
|
876
|
+
await teardownGate.promise;
|
|
877
|
+
if (!options.allowClosed && sandboxHandleLeaseAdmissionStates.isClosed(scope)) {
|
|
878
|
+
throw new Error(`Daytona sandbox lease ${scope.providerLeaseId} is no longer active.`);
|
|
879
|
+
}
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
const activityGate = await sandboxHandleActivityGates.begin(scope);
|
|
883
|
+
try {
|
|
884
|
+
// A teardown can still begin between the initial check above and the
|
|
885
|
+
// activity-gate admission. If that happens, back out and wait for the
|
|
886
|
+
// teardown to finish instead of proceeding into a race with cleanup.
|
|
887
|
+
if (sandboxHandleTeardownGates.current(scope)) {
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (!options.allowClosed && sandboxHandleLeaseAdmissionStates.isClosed(scope)) {
|
|
891
|
+
throw new Error(`Daytona sandbox lease ${scope.providerLeaseId} is no longer active.`);
|
|
892
|
+
}
|
|
893
|
+
return await fn();
|
|
894
|
+
}
|
|
895
|
+
finally {
|
|
896
|
+
sandboxHandleActivityGates.end(scope, activityGate);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
const sandboxHandleCache = (() => {
|
|
901
|
+
const entries = new Map();
|
|
902
|
+
function markFresh(scope) {
|
|
903
|
+
const entry = entries.get(sandboxHandleCacheKey(scope));
|
|
904
|
+
if (entry) {
|
|
905
|
+
entry.verifiedAtMs = handleFreshnessNow();
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
async function get(scope, options = {}) {
|
|
909
|
+
const key = sandboxHandleCacheKey(scope);
|
|
910
|
+
const entry = entries.get(key);
|
|
911
|
+
if (entry) {
|
|
912
|
+
// The warm handle cache holds an entry, so this lookup serves the handle
|
|
913
|
+
// without a `client.get` round trip. Report the cache decision now.
|
|
914
|
+
options.onCacheDecision?.(true);
|
|
915
|
+
const sandbox = await entry.sandbox;
|
|
916
|
+
// Re-assert on every hit; evict + fail closed on any mismatch (C2).
|
|
917
|
+
try {
|
|
918
|
+
assertHandleMatchesLease(sandbox, scope.providerLeaseId);
|
|
919
|
+
}
|
|
920
|
+
catch (error) {
|
|
921
|
+
entries.delete(key);
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
// Refresh the live provider state if the handle may have been auto-stopped
|
|
925
|
+
// since we last confirmed it, so the cached `state` snapshot can't hide a
|
|
926
|
+
// provider-initiated stop from `ensureSandboxStarted`. A failed refresh
|
|
927
|
+
// means the handle is no longer trustworthy — evict and fail closed.
|
|
928
|
+
const thresholdMs = staleHandleRefreshThresholdMs(scope.config.autoStopInterval);
|
|
929
|
+
if (thresholdMs != null && handleFreshnessNow() - entry.verifiedAtMs >= thresholdMs) {
|
|
930
|
+
try {
|
|
931
|
+
await withLivenessTimeout("sandbox.refreshData", scope.config.livenessTimeoutMs, () => sandbox.refreshData());
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
entries.delete(key);
|
|
935
|
+
throw error;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return sandbox;
|
|
939
|
+
}
|
|
940
|
+
// The warm handle cache holds no entry, so this lookup calls `client.get`.
|
|
941
|
+
// Report the cache decision now, before the single-flight populate.
|
|
942
|
+
options.onCacheDecision?.(false);
|
|
943
|
+
// Single-flight: the first miss stores the in-flight promise under the
|
|
944
|
+
// composite key so concurrent misses on the same lease share one `client.get`
|
|
945
|
+
// instead of double-fetching. The promise lives only under this key (C5).
|
|
946
|
+
const populate = (async () => {
|
|
947
|
+
const client = createDaytonaClient(scope.config);
|
|
948
|
+
const sandbox = await client.get(scope.providerLeaseId);
|
|
949
|
+
assertHandleMatchesLease(sandbox, scope.providerLeaseId);
|
|
950
|
+
return sandbox;
|
|
951
|
+
})();
|
|
952
|
+
const populated = { sandbox: populate, verifiedAtMs: handleFreshnessNow() };
|
|
953
|
+
entries.set(key, populated);
|
|
954
|
+
try {
|
|
955
|
+
const sandbox = await populate;
|
|
956
|
+
return sandbox;
|
|
957
|
+
}
|
|
958
|
+
catch (error) {
|
|
959
|
+
// A rejected populate (NotFound, network, id mismatch) must never remain
|
|
960
|
+
// cached (C4/C5). Guard against clobbering a newer entry under the key.
|
|
961
|
+
if (entries.get(key) === populated) {
|
|
962
|
+
entries.delete(key);
|
|
963
|
+
}
|
|
964
|
+
throw error;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
// Seed the cache with a handle the caller already holds (e.g. the fresh handle
|
|
968
|
+
// from `createSandbox` on a cold acquire), so the next `get` under the same
|
|
969
|
+
// scope reuses it instead of paying a real `client.get`. The seed must land
|
|
970
|
+
// under the exact composite key the reader uses, or the reader misses and the
|
|
971
|
+
// saved round trip is lost. Assert the handle belongs to the lease so a caller
|
|
972
|
+
// that builds a wrong scope fails loudly here instead of caching a foreign
|
|
973
|
+
// handle.
|
|
974
|
+
function seed(scope, sandbox) {
|
|
975
|
+
assertHandleMatchesLease(sandbox, scope.providerLeaseId);
|
|
976
|
+
entries.set(sandboxHandleCacheKey(scope), {
|
|
977
|
+
sandbox: Promise.resolve(sandbox),
|
|
978
|
+
verifiedAtMs: handleFreshnessNow(),
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
function clear(scope) {
|
|
982
|
+
entries.delete(sandboxHandleCacheKey(scope));
|
|
983
|
+
}
|
|
984
|
+
function reset() {
|
|
985
|
+
entries.clear();
|
|
986
|
+
}
|
|
987
|
+
// Resolve a cached sandbox by its provider lease id alone. The
|
|
988
|
+
// login pseudo-terminal open carries only the provider lease id, not the full
|
|
989
|
+
// scope, so this scans the cached handles for the one whose `sandbox.id`
|
|
990
|
+
// matches. The lease was cached on acquire in the same worker, so the scan is
|
|
991
|
+
// a hit for a live login lease. It returns null when no cached handle matches,
|
|
992
|
+
// so the caller fails closed.
|
|
993
|
+
async function findByProviderLeaseId(providerLeaseId) {
|
|
994
|
+
if (!providerLeaseId)
|
|
995
|
+
return null;
|
|
996
|
+
for (const entry of entries.values()) {
|
|
997
|
+
let sandbox;
|
|
998
|
+
try {
|
|
999
|
+
sandbox = await entry.sandbox;
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
if (sandbox.id === providerLeaseId)
|
|
1005
|
+
return sandbox;
|
|
1006
|
+
}
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
return { get, seed, clear, reset, markFresh, findByProviderLeaseId };
|
|
1010
|
+
})();
|
|
1011
|
+
// Preview credentials can rotate without a sandbox restart, so they must not
|
|
1012
|
+
// define endpoint generation. Daytona's lifecycle revision does: refreshData
|
|
1013
|
+
// updates `updatedAt` after stop/start. When Daytona does not expose a
|
|
1014
|
+
// lifecycle revision, the in-memory generation remains stable for the worker.
|
|
1015
|
+
const runnerIngressGenerationStore = (() => {
|
|
1016
|
+
const entries = new Map();
|
|
1017
|
+
function get(sandbox) {
|
|
1018
|
+
const revision = sandbox.updatedAt ?? sandbox.createdAt ?? null;
|
|
1019
|
+
const current = entries.get(sandbox.id);
|
|
1020
|
+
if (current && current.revision === revision)
|
|
1021
|
+
return current.generation;
|
|
1022
|
+
const generation = createHash("sha256")
|
|
1023
|
+
.update(`${sandbox.id}\0${revision ?? randomUUID()}`)
|
|
1024
|
+
.digest("hex");
|
|
1025
|
+
entries.set(sandbox.id, { revision, generation });
|
|
1026
|
+
return generation;
|
|
1027
|
+
}
|
|
1028
|
+
function reset() {
|
|
1029
|
+
entries.clear();
|
|
1030
|
+
}
|
|
1031
|
+
return { get, reset };
|
|
1032
|
+
})();
|
|
1033
|
+
// Advisory writable-set store. It holds, per lease scope, the sandbox
|
|
1034
|
+
// directories that a sync operation declared read-write (`access: "rw"`). The
|
|
1035
|
+
// store is advisory and best-effort in-memory state: it adds no security (the
|
|
1036
|
+
// ephemeral sandbox stays the only boundary). The store is keyed the same way
|
|
1037
|
+
// as `sandboxHandleCache`, by `sandboxHandleCacheKey(scope)`.
|
|
1038
|
+
//
|
|
1039
|
+
// The command path no longer reads this set. The provider dropped the advisory
|
|
1040
|
+
// `bwrap` wrapper that once bound these directories read-write for real-time
|
|
1041
|
+
// feedback (see `DIRECTORY-CONSTRAINT-FINDINGS.md`). The store still records the
|
|
1042
|
+
// read-write set, so a future isolation wrapper for the session can consume it
|
|
1043
|
+
// without a new sync change.
|
|
1044
|
+
const sandboxHandleWritableDirs = (() => {
|
|
1045
|
+
const dirsByKey = new Map();
|
|
1046
|
+
// Record the read-write destination directory of every `access: "rw"`
|
|
1047
|
+
// mapping. Skip read-only mappings (`access` absent or `"ro"`). Read-only is
|
|
1048
|
+
// the safe default for an advisory signal.
|
|
1049
|
+
//
|
|
1050
|
+
// A workspace, git-history, or asset mapping uploads a tar archive, so its
|
|
1051
|
+
// `targetPath` is the staging archive under the runtime root, not the directory
|
|
1052
|
+
// that the post-upload extract command fills. For those mappings the author
|
|
1053
|
+
// sets `writablePath` to the final destination directory, so this records the
|
|
1054
|
+
// real read-write destination, not the staging parent. When `writablePath` is
|
|
1055
|
+
// absent the mapping writes `targetPath` in place, so the parent directory of
|
|
1056
|
+
// `targetPath` is the destination.
|
|
1057
|
+
function recordWritableTargets(scope, operations) {
|
|
1058
|
+
const key = sandboxHandleCacheKey(scope);
|
|
1059
|
+
for (const operation of operations) {
|
|
1060
|
+
for (const mapping of operation.files) {
|
|
1061
|
+
if (mapping.access !== "rw")
|
|
1062
|
+
continue;
|
|
1063
|
+
let dirs = dirsByKey.get(key);
|
|
1064
|
+
if (!dirs) {
|
|
1065
|
+
dirs = new Set();
|
|
1066
|
+
dirsByKey.set(key, dirs);
|
|
1067
|
+
}
|
|
1068
|
+
dirs.add(mapping.writablePath ?? path.posix.dirname(mapping.targetPath));
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
function get(scope) {
|
|
1073
|
+
return dirsByKey.get(sandboxHandleCacheKey(scope)) ?? new Set();
|
|
1074
|
+
}
|
|
1075
|
+
function reset() {
|
|
1076
|
+
dirsByKey.clear();
|
|
1077
|
+
}
|
|
1078
|
+
return { recordWritableTargets, get, reset };
|
|
1079
|
+
})();
|
|
1080
|
+
// Per-lease Daytona session-id store. It holds, per lease scope, the id of the
|
|
1081
|
+
// one persistent session the exec hook opened for that lease. The store is
|
|
1082
|
+
// keyed the same way as `sandboxHandleCache`, by `sandboxHandleCacheKey(scope)`.
|
|
1083
|
+
// The exec hook creates one session on a cache miss and records its id here. The
|
|
1084
|
+
// teardown hooks delete the session and clear the id. A resume clears the id,
|
|
1085
|
+
// because a restarted sandbox loses its session shell, so the next exec must
|
|
1086
|
+
// open a fresh session. The store is process-memory only; it holds an id string,
|
|
1087
|
+
// never a handle, a credential, or a command.
|
|
1088
|
+
const sandboxHandleSessionStore = (() => {
|
|
1089
|
+
const idByKey = new Map();
|
|
1090
|
+
// In-flight session creates, keyed the same way as `idByKey`. A create records
|
|
1091
|
+
// its promise here for the time it runs, then removes it. The map lets two
|
|
1092
|
+
// overlapping first commands for one lease share one create. See `runSingle`.
|
|
1093
|
+
const pendingByKey = new Map();
|
|
1094
|
+
function get(scope) {
|
|
1095
|
+
return idByKey.get(sandboxHandleCacheKey(scope));
|
|
1096
|
+
}
|
|
1097
|
+
function set(scope, sessionId) {
|
|
1098
|
+
idByKey.set(sandboxHandleCacheKey(scope), sessionId);
|
|
1099
|
+
}
|
|
1100
|
+
function clear(scope) {
|
|
1101
|
+
idByKey.delete(sandboxHandleCacheKey(scope));
|
|
1102
|
+
}
|
|
1103
|
+
// Single-flight guard for the first-command session create. Two overlapping
|
|
1104
|
+
// first commands for one lease must open at most one live session. The first
|
|
1105
|
+
// caller runs `create` and records its in-flight promise; every concurrent
|
|
1106
|
+
// caller awaits the same promise instead of a second `create`. The store keeps
|
|
1107
|
+
// the promise only while `create` runs, then removes it, so a later command
|
|
1108
|
+
// (for example, after a resume clears the id) can open a fresh session. A
|
|
1109
|
+
// failed `create` removes the promise too, so the next command retries.
|
|
1110
|
+
function runSingle(scope, create) {
|
|
1111
|
+
const key = sandboxHandleCacheKey(scope);
|
|
1112
|
+
const inFlight = pendingByKey.get(key);
|
|
1113
|
+
if (inFlight)
|
|
1114
|
+
return inFlight;
|
|
1115
|
+
const promise = create();
|
|
1116
|
+
pendingByKey.set(key, promise);
|
|
1117
|
+
const settle = () => {
|
|
1118
|
+
pendingByKey.delete(key);
|
|
1119
|
+
};
|
|
1120
|
+
promise.then(settle, settle);
|
|
1121
|
+
return promise;
|
|
1122
|
+
}
|
|
1123
|
+
function reset() {
|
|
1124
|
+
idByKey.clear();
|
|
1125
|
+
pendingByKey.clear();
|
|
1126
|
+
}
|
|
1127
|
+
return { get, set, clear, runSingle, reset };
|
|
1128
|
+
})();
|
|
1129
|
+
/**
|
|
1130
|
+
* Test seam: clear the process-scoped handle cache between tests so a handle
|
|
1131
|
+
* memoized under a reused composite key in one test never leaks into the next.
|
|
1132
|
+
* Not used in production.
|
|
1133
|
+
*/
|
|
1134
|
+
export function __resetDaytonaSandboxHandleCacheForTest() {
|
|
1135
|
+
sandboxHandleCache.reset();
|
|
1136
|
+
sandboxHandleTeardownGates.reset();
|
|
1137
|
+
sandboxHandleActivityGates.reset();
|
|
1138
|
+
sandboxHandleLeaseAdmissionStates.reset();
|
|
1139
|
+
sandboxHandleWritableDirs.reset();
|
|
1140
|
+
sandboxHandleSessionStore.reset();
|
|
1141
|
+
runnerIngressGenerationStore.reset();
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Test seam: read the advisory writable directories recorded for a sync scope.
|
|
1145
|
+
* The caller passes the same `onEnvironmentSyncIn` inputs, so this rebuilds the
|
|
1146
|
+
* exact scope key the hook used. Not used in production.
|
|
1147
|
+
*/
|
|
1148
|
+
export function __getDaytonaWritableDirsForTest(input) {
|
|
1149
|
+
const scope = {
|
|
1150
|
+
driverKey: input.driverKey,
|
|
1151
|
+
companyId: input.companyId,
|
|
1152
|
+
environmentId: input.environmentId,
|
|
1153
|
+
providerLeaseId: input.lease.providerLeaseId ?? "",
|
|
1154
|
+
config: parseDriverConfig(input.config),
|
|
1155
|
+
};
|
|
1156
|
+
return [...sandboxHandleWritableDirs.get(scope)];
|
|
1157
|
+
}
|
|
1158
|
+
async function getSandbox(scope, options = {}) {
|
|
1159
|
+
return await sandboxHandleCache.get(scope, options);
|
|
1160
|
+
}
|
|
1161
|
+
async function getSandboxOrNull(scope, options = {}) {
|
|
1162
|
+
try {
|
|
1163
|
+
return await getSandbox(scope, options);
|
|
1164
|
+
}
|
|
1165
|
+
catch (error) {
|
|
1166
|
+
if (error instanceof DaytonaNotFoundError) {
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
throw error;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
function evictSandboxHandle(scope) {
|
|
1173
|
+
sandboxHandleCache.clear(scope);
|
|
1174
|
+
}
|
|
1175
|
+
// Return the persistent session id for a lease, and open one session on a cache
|
|
1176
|
+
// miss. The exec hook calls this once per command. The first call opens the
|
|
1177
|
+
// session through `createSession` and records its id; every later call returns
|
|
1178
|
+
// the stored id, so one lease runs every command in one persistent shell. The
|
|
1179
|
+
// provider never falls back to a one-shot command to open a session.
|
|
1180
|
+
//
|
|
1181
|
+
// Leak bound: the Daytona SDK exposes NO per-session TTL. `createSession` takes
|
|
1182
|
+
// only a session id, and there is no session update or expiry field. Two
|
|
1183
|
+
// backstops bound the session against a leak. First, `teardownSession` runs a
|
|
1184
|
+
// guaranteed `deleteSession` in every teardown hook's `try/finally`. Second, the
|
|
1185
|
+
// sandbox-level `autoStopInterval` (15 minutes idle by default) stops the
|
|
1186
|
+
// sandbox and, with it, every session; the `autoArchiveInterval` and
|
|
1187
|
+
// `autoDeleteInterval` intervals then reap the sandbox. A session is a shell
|
|
1188
|
+
// inside its sandbox and cannot outlive it.
|
|
1189
|
+
async function getOrCreateSession(sandbox, scope) {
|
|
1190
|
+
const existing = sandboxHandleSessionStore.get(scope);
|
|
1191
|
+
if (existing)
|
|
1192
|
+
return existing;
|
|
1193
|
+
// Single-flight the first-command create. Two overlapping first commands for
|
|
1194
|
+
// one lease share one create promise, so the lease opens at most one live
|
|
1195
|
+
// session. The guard checks and starts the create in one synchronous step, so
|
|
1196
|
+
// no second command can slip in between the store read and the create start.
|
|
1197
|
+
return sandboxHandleSessionStore.runSingle(scope, async () => {
|
|
1198
|
+
const sessionId = `paperclip-${randomUUID()}`;
|
|
1199
|
+
// Wrap the session create in a short `session.open` provider span. The span
|
|
1200
|
+
// carries no session id and no command text, only the provider family. The
|
|
1201
|
+
// host maps the name to `sandbox.daytona.session.open`.
|
|
1202
|
+
// `session.open` span: create the one persistent Daytona session for a lease,
|
|
1203
|
+
// on the first in-run command — `sandbox.process.createSession`.
|
|
1204
|
+
await withProviderSpan({
|
|
1205
|
+
name: "session.open",
|
|
1206
|
+
run: () => sandbox.process.createSession(sessionId),
|
|
1207
|
+
});
|
|
1208
|
+
sandboxHandleSessionStore.set(scope, sessionId);
|
|
1209
|
+
return sessionId;
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
// Delete the persistent session for a lease and clear its stored id. Each
|
|
1213
|
+
// teardown hook calls this inside its `try/finally`, so a failed delete never
|
|
1214
|
+
// skips the rest of teardown. A failed delete logs the session id and the error
|
|
1215
|
+
// loudly and does not throw past teardown; the sandbox stop or delete that
|
|
1216
|
+
// follows removes the session shell anyway, and the sandbox-level
|
|
1217
|
+
// `autoStopInterval` / `autoDeleteInterval` / `autoArchiveInterval` backstops
|
|
1218
|
+
// bound any residual state (the session API exposes no per-session TTL). The
|
|
1219
|
+
// store id is always cleared, so no orphan id survives.
|
|
1220
|
+
async function teardownSession(sandbox, scope) {
|
|
1221
|
+
const sessionId = sandboxHandleSessionStore.get(scope);
|
|
1222
|
+
if (!sessionId)
|
|
1223
|
+
return;
|
|
1224
|
+
// Retire the captured identity before awaiting the provider. A late response
|
|
1225
|
+
// must not clear a new session created after this lease is resumed.
|
|
1226
|
+
sandboxHandleSessionStore.clear(scope);
|
|
1227
|
+
try {
|
|
1228
|
+
// Wrap the session delete in a short `session.close` provider span. The
|
|
1229
|
+
// host maps the name to `sandbox.daytona.session.close`.
|
|
1230
|
+
// `session.close` span: delete that persistent session on lease release —
|
|
1231
|
+
// `sandbox.process.deleteSession`.
|
|
1232
|
+
await withProviderSpan({
|
|
1233
|
+
name: "session.close",
|
|
1234
|
+
run: () => sandbox.process.deleteSession(sessionId),
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
catch (error) {
|
|
1238
|
+
console.error(`Failed to delete Daytona session ${sessionId} during teardown: ${formatErrorMessage(error)}`);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
// One-shot command execution via Daytona's `process.executeCommand`. This is the
|
|
1242
|
+
// fallback path the exec hook uses when the session model is off. The command
|
|
1243
|
+
// runs plain as the unprivileged sandbox user; the provider no longer wraps a
|
|
1244
|
+
// user command with the advisory `bwrap` wrapper on any path.
|
|
1245
|
+
//
|
|
1246
|
+
// `executeCommand` returns combined stdout+stderr in `result`. We surface that
|
|
1247
|
+
// as `stdout` and leave `stderr` empty; callers that grep for error messages
|
|
1248
|
+
// still see them in `stdout`.
|
|
1249
|
+
async function executeOneShot(sandbox, params, config) {
|
|
1250
|
+
const gitNet = isGitNetworkCommand(params.command, params.args ?? []);
|
|
1251
|
+
const timeoutMs = resolveTimeoutMs(params.timeoutMs, config);
|
|
1252
|
+
const effectiveTimeoutMs = gitNet ? Math.min(timeoutMs, GIT_NETWORK_TIMEOUT_MS) : timeoutMs;
|
|
1253
|
+
const timeoutSeconds = toTimeoutSeconds(effectiveTimeoutMs);
|
|
1254
|
+
const stdinPath = params.stdin != null ? `/tmp/paperclip-stdin-${randomUUID()}` : null;
|
|
1255
|
+
// Marks the start of the `executeCommand` REST round-trip. Hoisted out of the
|
|
1256
|
+
// try so the timeout path below can still attribute the exec wall-time it spent
|
|
1257
|
+
// before the SDK aborted — a slow failed exec is exactly what we want to
|
|
1258
|
+
// measure. Stays null until we are about to call `executeCommand`, so a timeout
|
|
1259
|
+
// during the earlier `uploadFile` step honestly reports no `durationMs`.
|
|
1260
|
+
let execStart = null;
|
|
1261
|
+
try {
|
|
1262
|
+
if (stdinPath) {
|
|
1263
|
+
await sandbox.fs.uploadFile(Buffer.from(params.stdin ?? "", "utf8"), stdinPath, timeoutSeconds);
|
|
1264
|
+
}
|
|
1265
|
+
// Run the plain login-shell script as the unprivileged sandbox user. The
|
|
1266
|
+
// provider no longer wraps a user command with the advisory `bwrap` wrapper.
|
|
1267
|
+
const command = buildLoginShellScript({
|
|
1268
|
+
command: params.command,
|
|
1269
|
+
args: params.args ?? [],
|
|
1270
|
+
cwd: params.cwd,
|
|
1271
|
+
env: params.env,
|
|
1272
|
+
stdinPath: stdinPath ?? undefined,
|
|
1273
|
+
});
|
|
1274
|
+
// Pass cwd undefined: `buildLoginShellScript` already injects the `cd` after
|
|
1275
|
+
// it sources the login profiles, when params.cwd is set. The Daytona
|
|
1276
|
+
// executor's own cwd argument runs before that profile sourcing, which is
|
|
1277
|
+
// the wrong order (a profile could reset the caller env).
|
|
1278
|
+
// Time only the `executeCommand` REST round-trip so the caller can
|
|
1279
|
+
// attribute a step's exec time to the provider boundary through the
|
|
1280
|
+
// free-form `metadata.durationMs`.
|
|
1281
|
+
execStart = timingNow();
|
|
1282
|
+
const result = await sandbox.process.executeCommand(command, undefined, undefined, timeoutSeconds);
|
|
1283
|
+
const durationMs = timingNow() - execStart;
|
|
1284
|
+
return {
|
|
1285
|
+
exitCode: typeof result.exitCode === "number" ? result.exitCode : 1,
|
|
1286
|
+
timedOut: false,
|
|
1287
|
+
stdout: result.result ?? result.artifacts?.stdout ?? "",
|
|
1288
|
+
stderr: "",
|
|
1289
|
+
metadata: { durationMs },
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
catch (error) {
|
|
1293
|
+
if (error instanceof DaytonaTimeoutError) {
|
|
1294
|
+
const timeoutMessage = gitNet
|
|
1295
|
+
? `Git network operation timed out after ${Math.round(effectiveTimeoutMs / 1000)} s — the remote may be unreachable or noninteractive credentials are not configured.`
|
|
1296
|
+
: error.message.trim();
|
|
1297
|
+
// Preserve provider-boundary exec attribution on the timeout path: if the
|
|
1298
|
+
// SDK aborted the `executeCommand` call itself, report how long it ran
|
|
1299
|
+
// before timing out so slow failed startup exec is attributed to the
|
|
1300
|
+
// provider, not silently dropped.
|
|
1301
|
+
const durationMs = execStart != null ? timingNow() - execStart : undefined;
|
|
1302
|
+
return {
|
|
1303
|
+
exitCode: null,
|
|
1304
|
+
timedOut: true,
|
|
1305
|
+
stdout: "",
|
|
1306
|
+
stderr: `${timeoutMessage}\n`,
|
|
1307
|
+
...(durationMs != null ? { metadata: { durationMs } } : {}),
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
throw error;
|
|
1311
|
+
}
|
|
1312
|
+
finally {
|
|
1313
|
+
if (stdinPath) {
|
|
1314
|
+
await sandbox.fs.deleteFile(stdinPath).catch(() => undefined);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
// Poll interval for a session command's exit code. The live spike measured a
|
|
1319
|
+
// session command resolving in about 260-300 ms, so a short interval keeps the
|
|
1320
|
+
// poll responsive without a busy loop.
|
|
1321
|
+
const SESSION_POLL_INTERVAL_MS = 50;
|
|
1322
|
+
function sleep(ms) {
|
|
1323
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1324
|
+
}
|
|
1325
|
+
// Backoff delays for the exit-code read after the log stream ends. The live
|
|
1326
|
+
// spike measured the exit code available within one poll (91-202 ms), so the
|
|
1327
|
+
// first read almost always holds the code. These delays cover the rare case
|
|
1328
|
+
// where the first read has no code yet.
|
|
1329
|
+
const SESSION_EXIT_CODE_RETRY_DELAYS_MS = [50, 100, 200];
|
|
1330
|
+
// A bounded reconnect for the log stream. A disconnect settles the stream
|
|
1331
|
+
// promise as a rejection while the command still runs on the server. One
|
|
1332
|
+
// reconnect replays the log from byte 0; the stream buffer drops the replayed
|
|
1333
|
+
// prefix by byte offset. After this many reconnects the dispatch falls back to
|
|
1334
|
+
// the poll path.
|
|
1335
|
+
const MAX_SESSION_STREAM_RECONNECTS = 1;
|
|
1336
|
+
// Buffers the stdout and stderr of one session command from the callback log
|
|
1337
|
+
// stream, and drops a replayed prefix by byte offset.
|
|
1338
|
+
//
|
|
1339
|
+
// The Daytona callback stream replays the whole log from byte 0 after a
|
|
1340
|
+
// reconnect (it does not resume from an offset and does not omit earlier
|
|
1341
|
+
// bytes). So the buffer tracks the byte count it already holds per stream and
|
|
1342
|
+
// drops any replayed bytes that fall before that count. The dedupe runs at the
|
|
1343
|
+
// byte level, because Daytona replays the log byte-for-byte. The SDK keeps each
|
|
1344
|
+
// multibyte UTF-8 character whole per chunk and per stream, so the delivered
|
|
1345
|
+
// byte count always lands on a character boundary and the byte-offset split is
|
|
1346
|
+
// safe.
|
|
1347
|
+
//
|
|
1348
|
+
// The buffer stores each new tail as a separate chunk and joins the chunks one
|
|
1349
|
+
// time at read. It does not copy the earlier output on each append, so total
|
|
1350
|
+
// buffering work stays linear in the output size, not quadratic.
|
|
1351
|
+
function createSessionStreamBuffer(onNewTail) {
|
|
1352
|
+
const streams = {
|
|
1353
|
+
stdout: { chunks: [], length: 0, connectionBytes: 0 },
|
|
1354
|
+
stderr: { chunks: [], length: 0, connectionBytes: 0 },
|
|
1355
|
+
};
|
|
1356
|
+
function append(streamName, stream, chunk) {
|
|
1357
|
+
const buf = Buffer.from(chunk, "utf8");
|
|
1358
|
+
const start = stream.connectionBytes;
|
|
1359
|
+
stream.connectionBytes = start + buf.length;
|
|
1360
|
+
// The whole chunk falls before the delivered byte count, so it is a replay.
|
|
1361
|
+
if (start + buf.length <= stream.length) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
// Keep only the new tail. When the whole chunk is new, `start >=
|
|
1365
|
+
// stream.length` and the tail is the whole chunk. When the chunk straddles
|
|
1366
|
+
// the delivered byte count, the tail starts after the replayed prefix.
|
|
1367
|
+
const tail = start >= stream.length ? buf : buf.subarray(stream.length - start);
|
|
1368
|
+
stream.chunks.push(tail);
|
|
1369
|
+
stream.length += tail.length;
|
|
1370
|
+
// Deliver only the genuinely new tail to the live sink, so a replayed
|
|
1371
|
+
// prefix on a reconnect never reaches the host twice.
|
|
1372
|
+
if (onNewTail && tail.length > 0) {
|
|
1373
|
+
onNewTail(streamName, tail.toString("utf8"));
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return {
|
|
1377
|
+
onStdout: (chunk) => append("stdout", streams.stdout, chunk),
|
|
1378
|
+
onStderr: (chunk) => append("stderr", streams.stderr, chunk),
|
|
1379
|
+
// Reset the per-connection read cursors after a reconnect, so the replayed
|
|
1380
|
+
// prefix drops against the already-delivered byte count.
|
|
1381
|
+
resetConnectionCursors() {
|
|
1382
|
+
streams.stdout.connectionBytes = 0;
|
|
1383
|
+
streams.stderr.connectionBytes = 0;
|
|
1384
|
+
},
|
|
1385
|
+
get stdout() {
|
|
1386
|
+
return Buffer.concat(streams.stdout.chunks).toString("utf8");
|
|
1387
|
+
},
|
|
1388
|
+
get stderr() {
|
|
1389
|
+
return Buffer.concat(streams.stderr.chunks).toString("utf8");
|
|
1390
|
+
},
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
// Stream stdout and stderr of one session command from the callback log form.
|
|
1394
|
+
// The stream buffer drops a replayed prefix by byte offset on a reconnect. A
|
|
1395
|
+
// disconnect rejects the stream promise; the dispatch reconnects a bounded
|
|
1396
|
+
// number of times, then reports failure so the caller falls back to the poll
|
|
1397
|
+
// path.
|
|
1398
|
+
async function runSessionLogStream(sandbox, sessionId, commandId, onNewTail) {
|
|
1399
|
+
const buffer = createSessionStreamBuffer(onNewTail);
|
|
1400
|
+
let reconnects = 0;
|
|
1401
|
+
while (true) {
|
|
1402
|
+
try {
|
|
1403
|
+
await sandbox.process.getSessionCommandLogs(sessionId, commandId, buffer.onStdout, buffer.onStderr);
|
|
1404
|
+
return { ok: true, stdout: buffer.stdout, stderr: buffer.stderr };
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
if (reconnects >= MAX_SESSION_STREAM_RECONNECTS) {
|
|
1408
|
+
return { ok: false };
|
|
1409
|
+
}
|
|
1410
|
+
reconnects += 1;
|
|
1411
|
+
buffer.resetConnectionCursors();
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
// Read the exit code one time after the log stream ends. The exit code is
|
|
1416
|
+
// available within one poll, so the first read almost always holds it. Add a
|
|
1417
|
+
// small bounded retry with backoff only for the rare case where the first read
|
|
1418
|
+
// has no code yet. Return null when no read holds a numeric code.
|
|
1419
|
+
async function readSessionExitCode(sandbox, sessionId, commandId) {
|
|
1420
|
+
const first = await sandbox.process.getSessionCommand(sessionId, commandId);
|
|
1421
|
+
if (typeof first.exitCode === "number") {
|
|
1422
|
+
return first.exitCode;
|
|
1423
|
+
}
|
|
1424
|
+
for (const delayMs of SESSION_EXIT_CODE_RETRY_DELAYS_MS) {
|
|
1425
|
+
await sleep(delayMs);
|
|
1426
|
+
const status = await sandbox.process.getSessionCommand(sessionId, commandId);
|
|
1427
|
+
if (typeof status.exitCode === "number") {
|
|
1428
|
+
return status.exitCode;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
// Dispatch one user command into the persistent session and return its true
|
|
1434
|
+
// stdout and stderr.
|
|
1435
|
+
//
|
|
1436
|
+
// The Daytona session is one persistent shell. A top-level `exit N` inside a
|
|
1437
|
+
// session command ends that shell, so the next command then fails with "session
|
|
1438
|
+
// process has exited". To stop a user `exit` from reaching the session shell,
|
|
1439
|
+
// the dispatch wraps the whole login-shell script in a subshell `( ... )`. A
|
|
1440
|
+
// user `exit` then ends only the subshell and reports its exit code, and the
|
|
1441
|
+
// session shell stays alive. The provider passes the caller cwd and env inside
|
|
1442
|
+
// the login-shell script on every command, so it never relies on implicit state
|
|
1443
|
+
// that leaks between commands.
|
|
1444
|
+
//
|
|
1445
|
+
// The SDK exposes no built-in wait for a session command, so the dispatch runs
|
|
1446
|
+
// the command with `runAsync: true` and polls `getSessionCommand` until the exit
|
|
1447
|
+
// code is set. It then reads true `stdout` and `stderr` from
|
|
1448
|
+
// `getSessionCommandLogs`, because the synchronous response fields are optional.
|
|
1449
|
+
// The `runAsync: true` path also avoids the known `runAsync: false` login-shell
|
|
1450
|
+
// hang.
|
|
1451
|
+
async function executeInSession(sandbox, sessionId, params, config) {
|
|
1452
|
+
const gitNet = isGitNetworkCommand(params.command, params.args ?? []);
|
|
1453
|
+
const timeoutMs = resolveTimeoutMs(params.timeoutMs, config);
|
|
1454
|
+
const effectiveTimeoutMs = gitNet ? Math.min(timeoutMs, GIT_NETWORK_TIMEOUT_MS) : timeoutMs;
|
|
1455
|
+
const timeoutSeconds = toTimeoutSeconds(effectiveTimeoutMs);
|
|
1456
|
+
const stdinPath = params.stdin != null ? `/tmp/paperclip-stdin-${randomUUID()}` : null;
|
|
1457
|
+
// Marks the start of the session dispatch and poll. The timeout paths report
|
|
1458
|
+
// the exec wall-time spent before the abort, so a slow command is still
|
|
1459
|
+
// attributed to the provider boundary.
|
|
1460
|
+
let execStart = null;
|
|
1461
|
+
try {
|
|
1462
|
+
if (stdinPath) {
|
|
1463
|
+
await sandbox.fs.uploadFile(Buffer.from(params.stdin ?? "", "utf8"), stdinPath, timeoutSeconds);
|
|
1464
|
+
}
|
|
1465
|
+
const loginScript = buildLoginShellScript({
|
|
1466
|
+
command: params.command,
|
|
1467
|
+
args: params.args ?? [],
|
|
1468
|
+
cwd: params.cwd,
|
|
1469
|
+
env: params.env,
|
|
1470
|
+
stdinPath: stdinPath ?? undefined,
|
|
1471
|
+
});
|
|
1472
|
+
// Subshell wrap: a top-level `exit` in the user command exits only the
|
|
1473
|
+
// subshell, not the persistent session shell.
|
|
1474
|
+
const command = `( ${loginScript} )`;
|
|
1475
|
+
execStart = timingNow();
|
|
1476
|
+
const dispatched = await sandbox.process.executeSessionCommand(sessionId, { command, runAsync: true }, timeoutSeconds);
|
|
1477
|
+
const commandId = dispatched.cmdId;
|
|
1478
|
+
// Log-stream path. A session command always tries the stream first: it
|
|
1479
|
+
// streams stdout and stderr from the callback log form, then reads the exit
|
|
1480
|
+
// code one time. On a stream failure, fall through to the poll path below,
|
|
1481
|
+
// because the command still runs to its exit on the server.
|
|
1482
|
+
//
|
|
1483
|
+
// Emit each genuinely new output chunk to the host during the active execute
|
|
1484
|
+
// call. The host routes it to the runner log sink by the host-issued
|
|
1485
|
+
// invocation id. This is a no-op when no plugin context is set (a direct
|
|
1486
|
+
// test call) or when the host has no active execute route.
|
|
1487
|
+
const streamResult = await runSessionLogStream(sandbox, sessionId, commandId, (stream, text) => pluginContext?.execution.log(stream, text));
|
|
1488
|
+
if (streamResult.ok) {
|
|
1489
|
+
const exitCode = await readSessionExitCode(sandbox, sessionId, commandId);
|
|
1490
|
+
const durationMs = timingNow() - execStart;
|
|
1491
|
+
return {
|
|
1492
|
+
exitCode,
|
|
1493
|
+
timedOut: false,
|
|
1494
|
+
stdout: streamResult.stdout,
|
|
1495
|
+
stderr: streamResult.stderr,
|
|
1496
|
+
metadata: { durationMs },
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
// Poll for the exit code; the SDK has no wait method. The poll deadline uses
|
|
1500
|
+
// the wall clock, separate from the injected timing clock that measures the
|
|
1501
|
+
// reported `durationMs`. The poll path is the fallback when the log stream
|
|
1502
|
+
// fails.
|
|
1503
|
+
const deadlineMs = Date.now() + effectiveTimeoutMs;
|
|
1504
|
+
let exitCode = null;
|
|
1505
|
+
while (true) {
|
|
1506
|
+
const status = await sandbox.process.getSessionCommand(sessionId, commandId);
|
|
1507
|
+
if (typeof status.exitCode === "number") {
|
|
1508
|
+
exitCode = status.exitCode;
|
|
1509
|
+
break;
|
|
1510
|
+
}
|
|
1511
|
+
if (Date.now() >= deadlineMs) {
|
|
1512
|
+
const durationMs = timingNow() - execStart;
|
|
1513
|
+
const timeoutMessage = gitNet
|
|
1514
|
+
? `Git network operation timed out after ${Math.round(effectiveTimeoutMs / 1000)} s — the remote may be unreachable or noninteractive credentials are not configured.`
|
|
1515
|
+
: `Command timed out after ${Math.round(effectiveTimeoutMs / 1000)} s.`;
|
|
1516
|
+
return {
|
|
1517
|
+
exitCode: null,
|
|
1518
|
+
timedOut: true,
|
|
1519
|
+
stdout: "",
|
|
1520
|
+
stderr: `${timeoutMessage}\n`,
|
|
1521
|
+
metadata: { durationMs },
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
await sleep(SESSION_POLL_INTERVAL_MS);
|
|
1525
|
+
}
|
|
1526
|
+
// Read true, separated stdout and stderr from the logs endpoint. The
|
|
1527
|
+
// synchronous dispatch response fields are optional, so the logs endpoint is
|
|
1528
|
+
// the source of truth.
|
|
1529
|
+
const logs = await sandbox.process.getSessionCommandLogs(sessionId, commandId);
|
|
1530
|
+
const durationMs = timingNow() - execStart;
|
|
1531
|
+
return {
|
|
1532
|
+
exitCode,
|
|
1533
|
+
timedOut: false,
|
|
1534
|
+
stdout: logs.stdout ?? "",
|
|
1535
|
+
stderr: logs.stderr ?? "",
|
|
1536
|
+
metadata: { durationMs },
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
catch (error) {
|
|
1540
|
+
if (error instanceof DaytonaTimeoutError) {
|
|
1541
|
+
const timeoutMessage = gitNet
|
|
1542
|
+
? `Git network operation timed out after ${Math.round(effectiveTimeoutMs / 1000)} s — the remote may be unreachable or noninteractive credentials are not configured.`
|
|
1543
|
+
: error.message.trim();
|
|
1544
|
+
const durationMs = execStart != null ? timingNow() - execStart : undefined;
|
|
1545
|
+
return {
|
|
1546
|
+
exitCode: null,
|
|
1547
|
+
timedOut: true,
|
|
1548
|
+
stdout: "",
|
|
1549
|
+
stderr: `${timeoutMessage}\n`,
|
|
1550
|
+
...(durationMs != null ? { metadata: { durationMs } } : {}),
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
throw error;
|
|
1554
|
+
}
|
|
1555
|
+
finally {
|
|
1556
|
+
if (stdinPath) {
|
|
1557
|
+
await sandbox.fs.deleteFile(stdinPath).catch(() => undefined);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
const daytonaLoginPtyByRoute = new Map();
|
|
1562
|
+
const daytonaLoginPtyBySession = new Map();
|
|
1563
|
+
function forgetDaytonaLoginPty(entry) {
|
|
1564
|
+
daytonaLoginPtyByRoute.delete(entry.hostRouteId);
|
|
1565
|
+
daytonaLoginPtyBySession.delete(entry.workerSessionId);
|
|
1566
|
+
}
|
|
1567
|
+
const daytonaDuplexChannelByRoute = new Map();
|
|
1568
|
+
const daytonaDuplexChannelBySession = new Map();
|
|
1569
|
+
function forgetDaytonaDuplexChannel(entry) {
|
|
1570
|
+
daytonaDuplexChannelByRoute.delete(entry.hostRouteId);
|
|
1571
|
+
daytonaDuplexChannelBySession.delete(entry.workerSessionId);
|
|
1572
|
+
}
|
|
1573
|
+
// Close every open duplex channel that belongs to one provider lease and drop its
|
|
1574
|
+
// entry. The lease teardown hooks (release, destroy, resume) call this, so a
|
|
1575
|
+
// channel never outlives the sandbox that carries it. The close kills the child
|
|
1576
|
+
// and releases the pseudo-terminal socket, so no live channel survives the
|
|
1577
|
+
// teardown. The stored identifiers are always cleared, so no orphan id survives.
|
|
1578
|
+
async function closeDaytonaDuplexChannelsForLease(providerLeaseId) {
|
|
1579
|
+
const matches = [...daytonaDuplexChannelByRoute.values()].filter((entry) => entry.providerLeaseId === providerLeaseId);
|
|
1580
|
+
for (const entry of matches)
|
|
1581
|
+
forgetDaytonaDuplexChannel(entry);
|
|
1582
|
+
await Promise.allSettled(matches.map(entry => entry.session.close()));
|
|
1583
|
+
}
|
|
1584
|
+
const plugin = definePlugin({
|
|
1585
|
+
async setup(ctx) {
|
|
1586
|
+
// Hoist the context to a module variable so the lifecycle hooks and the
|
|
1587
|
+
// file-sync helpers can read `ctx.tracer` — they have no closure over `ctx`.
|
|
1588
|
+
pluginContext = ctx;
|
|
1589
|
+
ctx.logger.info("Daytona sandbox provider plugin ready");
|
|
1590
|
+
},
|
|
1591
|
+
async onHealth() {
|
|
1592
|
+
return { status: "ok", message: "Daytona sandbox provider plugin healthy" };
|
|
1593
|
+
},
|
|
1594
|
+
async onEnvironmentValidateConfig(params) {
|
|
1595
|
+
const config = parseDriverConfig(params.config);
|
|
1596
|
+
const errors = [];
|
|
1597
|
+
if (typeof params.config.image === "string" && params.config.image.trim().length === 0) {
|
|
1598
|
+
errors.push("Daytona image cannot be empty.");
|
|
1599
|
+
}
|
|
1600
|
+
if (typeof params.config.snapshot === "string" && params.config.snapshot.trim().length === 0) {
|
|
1601
|
+
errors.push("Daytona snapshot cannot be empty.");
|
|
1602
|
+
}
|
|
1603
|
+
if (config.image && config.snapshot) {
|
|
1604
|
+
errors.push("Daytona sandbox environments must set either image or snapshot, not both.");
|
|
1605
|
+
}
|
|
1606
|
+
if (config.apiUrl && !isValidUrl(config.apiUrl)) {
|
|
1607
|
+
errors.push("apiUrl must be a valid URL.");
|
|
1608
|
+
}
|
|
1609
|
+
if (config.timeoutMs < 1 || config.timeoutMs > 86_400_000) {
|
|
1610
|
+
errors.push("timeoutMs must be between 1 and 86400000.");
|
|
1611
|
+
}
|
|
1612
|
+
// A value of 0 or less disables the extra bound on purpose; reject only a
|
|
1613
|
+
// value above the outer RPC ceiling, which would make the bound useless.
|
|
1614
|
+
if (config.livenessTimeoutMs > 86_400_000) {
|
|
1615
|
+
errors.push("livenessTimeoutMs must be less than or equal to 86400000.");
|
|
1616
|
+
}
|
|
1617
|
+
if (config.autoStopInterval != null && config.autoStopInterval < 0) {
|
|
1618
|
+
errors.push("autoStopInterval must be greater than or equal to 0.");
|
|
1619
|
+
}
|
|
1620
|
+
if (config.autoArchiveInterval != null && config.autoArchiveInterval < 0) {
|
|
1621
|
+
errors.push("autoArchiveInterval must be greater than or equal to 0.");
|
|
1622
|
+
}
|
|
1623
|
+
if (config.autoDeleteInterval != null && config.autoDeleteInterval < -1) {
|
|
1624
|
+
errors.push("autoDeleteInterval must be greater than or equal to -1.");
|
|
1625
|
+
}
|
|
1626
|
+
if (!config.apiKey && !(process.env.DAYTONA_API_KEY?.trim())) {
|
|
1627
|
+
errors.push("Daytona sandbox environments require an API key in config or DAYTONA_API_KEY.");
|
|
1628
|
+
}
|
|
1629
|
+
const resourceRequestError = validateResourceRequest(config);
|
|
1630
|
+
if (resourceRequestError) {
|
|
1631
|
+
errors.push(resourceRequestError);
|
|
1632
|
+
}
|
|
1633
|
+
for (const [key, value] of Object.entries({
|
|
1634
|
+
cpu: config.cpu,
|
|
1635
|
+
memory: config.memory,
|
|
1636
|
+
disk: config.disk,
|
|
1637
|
+
gpu: config.gpu,
|
|
1638
|
+
})) {
|
|
1639
|
+
if (value != null && value <= 0) {
|
|
1640
|
+
errors.push(`${key} must be greater than 0 when provided.`);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
if (errors.length > 0) {
|
|
1644
|
+
return { ok: false, errors };
|
|
1645
|
+
}
|
|
1646
|
+
return {
|
|
1647
|
+
ok: true,
|
|
1648
|
+
normalizedConfig: { ...config },
|
|
1649
|
+
};
|
|
1650
|
+
},
|
|
1651
|
+
async onEnvironmentProbe(params) {
|
|
1652
|
+
const config = parseDriverConfig(params.config);
|
|
1653
|
+
try {
|
|
1654
|
+
const sandbox = await createSandbox(params, config);
|
|
1655
|
+
try {
|
|
1656
|
+
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
|
1657
|
+
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
1658
|
+
return {
|
|
1659
|
+
ok: true,
|
|
1660
|
+
summary: `Connected to Daytona sandbox ${sandbox.name}.`,
|
|
1661
|
+
metadata: {
|
|
1662
|
+
provider: "daytona",
|
|
1663
|
+
shellCommand,
|
|
1664
|
+
sandboxId: sandbox.id,
|
|
1665
|
+
sandboxName: sandbox.name,
|
|
1666
|
+
target: sandbox.target,
|
|
1667
|
+
image: config.image,
|
|
1668
|
+
snapshot: config.snapshot,
|
|
1669
|
+
timeoutMs: config.timeoutMs,
|
|
1670
|
+
reuseLease: config.reuseLease,
|
|
1671
|
+
remoteCwd,
|
|
1672
|
+
},
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
finally {
|
|
1676
|
+
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
catch (error) {
|
|
1680
|
+
return {
|
|
1681
|
+
ok: false,
|
|
1682
|
+
summary: "Daytona sandbox probe failed.",
|
|
1683
|
+
metadata: {
|
|
1684
|
+
provider: "daytona",
|
|
1685
|
+
image: config.image,
|
|
1686
|
+
snapshot: config.snapshot,
|
|
1687
|
+
timeoutMs: config.timeoutMs,
|
|
1688
|
+
reuseLease: config.reuseLease,
|
|
1689
|
+
error: formatErrorMessage(error),
|
|
1690
|
+
},
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
},
|
|
1694
|
+
async onEnvironmentAcquireLease(params) {
|
|
1695
|
+
const config = parseDriverConfig(params.config);
|
|
1696
|
+
const sandbox = await createSandbox(params, config);
|
|
1697
|
+
try {
|
|
1698
|
+
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
|
1699
|
+
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
1700
|
+
// Configure a provider-side destroy time at or before a caller deadline, so
|
|
1701
|
+
// an abandoned sandbox self-destroys even if Paperclip is down. The lease
|
|
1702
|
+
// carries the real provider expiry (or none) as evidence of the bound.
|
|
1703
|
+
const expiresAt = await configureSandboxExpiry({
|
|
1704
|
+
sandbox,
|
|
1705
|
+
requestedExpiresAt: params.requestedExpiresAt,
|
|
1706
|
+
nowMs: Date.now(),
|
|
1707
|
+
});
|
|
1708
|
+
const workspaceSentinel = await writeWorkspaceSentinel({
|
|
1709
|
+
sandbox,
|
|
1710
|
+
remoteCwd,
|
|
1711
|
+
params,
|
|
1712
|
+
config,
|
|
1713
|
+
timeoutSeconds: toTimeoutSeconds(config.timeoutMs),
|
|
1714
|
+
});
|
|
1715
|
+
sandboxHandleLeaseAdmissionStates.open({
|
|
1716
|
+
driverKey: params.driverKey,
|
|
1717
|
+
companyId: params.companyId,
|
|
1718
|
+
environmentId: params.environmentId,
|
|
1719
|
+
providerLeaseId: sandbox.id,
|
|
1720
|
+
config,
|
|
1721
|
+
});
|
|
1722
|
+
// Seed the handle cache with the fresh handle under the exact scope that
|
|
1723
|
+
// `onEnvironmentRealizeWorkspace` reads (providerLeaseId === sandbox.id).
|
|
1724
|
+
// Realize then reuses this handle instead of paying a real `client.get`.
|
|
1725
|
+
sandboxHandleCache.seed({
|
|
1726
|
+
driverKey: params.driverKey,
|
|
1727
|
+
companyId: params.companyId,
|
|
1728
|
+
environmentId: params.environmentId,
|
|
1729
|
+
providerLeaseId: sandbox.id,
|
|
1730
|
+
config,
|
|
1731
|
+
}, sandbox);
|
|
1732
|
+
return {
|
|
1733
|
+
providerLeaseId: sandbox.id,
|
|
1734
|
+
expiresAt,
|
|
1735
|
+
metadata: leaseMetadata({
|
|
1736
|
+
config,
|
|
1737
|
+
sandbox,
|
|
1738
|
+
shellCommand,
|
|
1739
|
+
remoteCwd,
|
|
1740
|
+
resumedLease: false,
|
|
1741
|
+
workspaceSentinel,
|
|
1742
|
+
}),
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
catch (error) {
|
|
1746
|
+
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
|
|
1747
|
+
throw error;
|
|
1748
|
+
}
|
|
1749
|
+
},
|
|
1750
|
+
async onEnvironmentResumeLease(params) {
|
|
1751
|
+
const config = parseDriverConfig(params.config);
|
|
1752
|
+
const scope = {
|
|
1753
|
+
driverKey: params.driverKey,
|
|
1754
|
+
companyId: params.companyId,
|
|
1755
|
+
environmentId: params.environmentId,
|
|
1756
|
+
providerLeaseId: params.providerLeaseId,
|
|
1757
|
+
config,
|
|
1758
|
+
};
|
|
1759
|
+
// A confirmed stop may precede completion of an old SDK request. Do not
|
|
1760
|
+
// restart its sandbox under that request; it could still write or execute.
|
|
1761
|
+
if (sandboxHandleLeaseAdmissionStates.isClosed(scope) && sandboxHandleActivityGates.isActive(scope)) {
|
|
1762
|
+
throw new Error("The stopped Daytona sandbox is still settling cancelled work. Retry shortly.");
|
|
1763
|
+
}
|
|
1764
|
+
return await withSandboxActivityGate(scope, async () => {
|
|
1765
|
+
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
|
1766
|
+
if (!sandbox) {
|
|
1767
|
+
return { providerLeaseId: null, metadata: { expired: true } };
|
|
1768
|
+
}
|
|
1769
|
+
// Daytona can retain the API record after losing its container. Confirm
|
|
1770
|
+
// the exact provider report again before allowing the host's existing
|
|
1771
|
+
// backup-guarded replacement path. Unknown errors preserve the lease.
|
|
1772
|
+
if (hasMissingSandboxContainer(sandbox)) {
|
|
1773
|
+
try {
|
|
1774
|
+
await withLivenessTimeout("sandbox.refreshData", config.livenessTimeoutMs, () => sandbox.refreshData());
|
|
1775
|
+
assertHandleMatchesLease(sandbox, params.providerLeaseId);
|
|
1776
|
+
}
|
|
1777
|
+
catch (error) {
|
|
1778
|
+
evictSandboxHandle(scope);
|
|
1779
|
+
if (error instanceof DaytonaNotFoundError) {
|
|
1780
|
+
return { providerLeaseId: null, metadata: { expired: true } };
|
|
1781
|
+
}
|
|
1782
|
+
throw error;
|
|
1783
|
+
}
|
|
1784
|
+
if (hasMissingSandboxContainer(sandbox)) {
|
|
1785
|
+
evictSandboxHandle(scope);
|
|
1786
|
+
return { providerLeaseId: null, metadata: { expired: true } };
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
// A stopped sandbox loses its session shell, so the stored session id is
|
|
1790
|
+
// stale after a real restart. Clear the id only when the sandbox is not
|
|
1791
|
+
// already running, and clear it before the restart. A stopped sandbox has
|
|
1792
|
+
// no live session, so the clear drops a dead id and a later command opens
|
|
1793
|
+
// a fresh session. A running sandbox keeps its live session, so the resume
|
|
1794
|
+
// leaves the id in place; a concurrent command still finds it and teardown
|
|
1795
|
+
// deletes one session. An unconditional clear would drop the id of a live
|
|
1796
|
+
// session and leak its shell until sandbox reaping.
|
|
1797
|
+
if (sandbox.state !== "started") {
|
|
1798
|
+
sandboxHandleSessionStore.clear(scope);
|
|
1799
|
+
// A stopped sandbox loses its pseudo-terminals, so a stored duplex channel
|
|
1800
|
+
// is dead after a real restart. Close and drop every channel on this lease
|
|
1801
|
+
// before the restart, so no stale channel id survives the resume.
|
|
1802
|
+
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
|
|
1803
|
+
}
|
|
1804
|
+
const resumedFromState = sandbox.state ?? null;
|
|
1805
|
+
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
1806
|
+
try {
|
|
1807
|
+
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
|
1808
|
+
// C3: a resumed lease must clear the workspace sentinel before it is
|
|
1809
|
+
// trusted, even when the handle came from the cache. On any non-match we
|
|
1810
|
+
// evict the cached handle and expire the lease so a stale/foreign sandbox
|
|
1811
|
+
// is never reused on the subsequent (sentinel-skipping) exec path.
|
|
1812
|
+
const workspaceSentinel = await verifyWorkspaceSentinel({
|
|
1813
|
+
sandbox,
|
|
1814
|
+
remoteCwd,
|
|
1815
|
+
leaseMetadata: params.leaseMetadata,
|
|
1816
|
+
timeoutSeconds: toTimeoutSeconds(config.timeoutMs),
|
|
1817
|
+
});
|
|
1818
|
+
if (workspaceSentinel.result !== "matched") {
|
|
1819
|
+
evictSandboxHandle(scope);
|
|
1820
|
+
return {
|
|
1821
|
+
providerLeaseId: null,
|
|
1822
|
+
metadata: { expired: true, workspaceSentinel },
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
1826
|
+
sandboxHandleCache.markFresh(scope);
|
|
1827
|
+
sandboxHandleLeaseAdmissionStates.open(scope);
|
|
1828
|
+
return {
|
|
1829
|
+
providerLeaseId: sandbox.id,
|
|
1830
|
+
metadata: leaseMetadata({
|
|
1831
|
+
config,
|
|
1832
|
+
sandbox,
|
|
1833
|
+
shellCommand,
|
|
1834
|
+
remoteCwd,
|
|
1835
|
+
resumedLease: true,
|
|
1836
|
+
resumedFromState,
|
|
1837
|
+
sandboxState: "started",
|
|
1838
|
+
workspaceSentinel,
|
|
1839
|
+
}),
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
catch (error) {
|
|
1843
|
+
evictSandboxHandle(scope);
|
|
1844
|
+
// A timeout, rate limit, or provider 5xx does not prove this sandbox is
|
|
1845
|
+
// lost. Preserve the exact resource and let the host retry its recorded
|
|
1846
|
+
// lease; replacement is permitted only after an explicit not-found or
|
|
1847
|
+
// an immutable workspace identity mismatch.
|
|
1848
|
+
throw error;
|
|
1849
|
+
}
|
|
1850
|
+
}, { allowClosed: true });
|
|
1851
|
+
},
|
|
1852
|
+
async onEnvironmentReleaseLease(params) {
|
|
1853
|
+
if (!params.providerLeaseId)
|
|
1854
|
+
return;
|
|
1855
|
+
const config = parseDriverConfig(params.config);
|
|
1856
|
+
const scope = {
|
|
1857
|
+
driverKey: params.driverKey,
|
|
1858
|
+
companyId: params.companyId,
|
|
1859
|
+
environmentId: params.environmentId,
|
|
1860
|
+
providerLeaseId: params.providerLeaseId,
|
|
1861
|
+
config,
|
|
1862
|
+
};
|
|
1863
|
+
// C4: the lease's handle must not outlive its teardown. A teardown gate
|
|
1864
|
+
// blocks fresh cache reads while cleanup is in flight so overlapping
|
|
1865
|
+
// exec/sync calls cannot reacquire the same sandbox mid-stop/delete.
|
|
1866
|
+
const teardownGate = sandboxHandleTeardownGates.begin(scope);
|
|
1867
|
+
sandboxHandleLeaseAdmissionStates.close(scope);
|
|
1868
|
+
try {
|
|
1869
|
+
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
|
1870
|
+
if (!sandbox)
|
|
1871
|
+
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
|
1872
|
+
evictSandboxHandle(scope);
|
|
1873
|
+
if (params.cancelActiveWork) {
|
|
1874
|
+
// Graceful release waits for activity below. Stop must not wait on the
|
|
1875
|
+
// command it is cancelling. Admission is already closed for this lease.
|
|
1876
|
+
const timeoutSeconds = Math.min(toTimeoutSeconds(config.timeoutMs), 30);
|
|
1877
|
+
await withLivenessTimeout("sandbox.refreshData", Math.min(config.livenessTimeoutMs, 30_000), () => sandbox.refreshData());
|
|
1878
|
+
if (sandbox.state !== "stopped")
|
|
1879
|
+
await sandbox.stop(timeoutSeconds);
|
|
1880
|
+
sandboxHandleSessionStore.clear(scope);
|
|
1881
|
+
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
|
|
1882
|
+
return { providerLeaseId: params.providerLeaseId, state: "stopped" };
|
|
1883
|
+
}
|
|
1884
|
+
await drainSandboxBeforeTermination(sandbox, scope);
|
|
1885
|
+
// A cached stopped state is not a receipt: the resource could have been
|
|
1886
|
+
// resumed since the handle was cached. Read provider state at this boundary.
|
|
1887
|
+
await withLivenessTimeout("sandbox.refreshData", config.livenessTimeoutMs, () => sandbox.refreshData());
|
|
1888
|
+
if (config.reuseLease) {
|
|
1889
|
+
if (sandbox.state !== "stopped") {
|
|
1890
|
+
try {
|
|
1891
|
+
await terminateAtProvider(scope, "sandbox.stop", () => sandbox.stop(toTimeoutSeconds(config.timeoutMs)));
|
|
1892
|
+
}
|
|
1893
|
+
catch (error) {
|
|
1894
|
+
console.warn(`Failed to stop Daytona sandbox during lease release: ${formatErrorMessage(error)}. Attempting delete instead.`);
|
|
1895
|
+
await terminateAtProvider(scope, "sandbox.delete", () => sandbox.delete(toTimeoutSeconds(config.timeoutMs), true));
|
|
1896
|
+
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
return { providerLeaseId: params.providerLeaseId, state: "stopped" };
|
|
1900
|
+
}
|
|
1901
|
+
if (config.archiveOnRelease) {
|
|
1902
|
+
try {
|
|
1903
|
+
if (sandbox.state !== "stopped") {
|
|
1904
|
+
await terminateAtProvider(scope, "sandbox.stop", () => sandbox.stop(toTimeoutSeconds(config.timeoutMs)));
|
|
1905
|
+
}
|
|
1906
|
+
await sandbox.setAutoDeleteInterval(ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES);
|
|
1907
|
+
await sandbox.archive();
|
|
1908
|
+
return { providerLeaseId: params.providerLeaseId, state: "stopped" };
|
|
1909
|
+
}
|
|
1910
|
+
catch (error) {
|
|
1911
|
+
console.warn(`Failed to archive Daytona sandbox during lease release: ${formatErrorMessage(error)}. Falling back to delete.`);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
await terminateAtProvider(scope, "sandbox.delete", () => sandbox.delete(toTimeoutSeconds(config.timeoutMs), true));
|
|
1915
|
+
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
|
1916
|
+
}
|
|
1917
|
+
finally {
|
|
1918
|
+
sandboxHandleTeardownGates.end(scope, teardownGate);
|
|
1919
|
+
evictSandboxHandle(scope);
|
|
1920
|
+
}
|
|
1921
|
+
},
|
|
1922
|
+
async onEnvironmentDestroyLease(params) {
|
|
1923
|
+
if (!params.providerLeaseId)
|
|
1924
|
+
return;
|
|
1925
|
+
const config = parseDriverConfig(params.config);
|
|
1926
|
+
const scope = {
|
|
1927
|
+
driverKey: params.driverKey,
|
|
1928
|
+
companyId: params.companyId,
|
|
1929
|
+
environmentId: params.environmentId,
|
|
1930
|
+
providerLeaseId: params.providerLeaseId,
|
|
1931
|
+
config,
|
|
1932
|
+
};
|
|
1933
|
+
// C4: the teardown gate blocks fresh cache reads while delete is in flight
|
|
1934
|
+
// so overlapping exec/sync calls cannot reacquire the same sandbox mid-teardown.
|
|
1935
|
+
const teardownGate = sandboxHandleTeardownGates.begin(scope);
|
|
1936
|
+
sandboxHandleLeaseAdmissionStates.close(scope);
|
|
1937
|
+
try {
|
|
1938
|
+
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
|
1939
|
+
if (!sandbox)
|
|
1940
|
+
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
|
1941
|
+
evictSandboxHandle(scope);
|
|
1942
|
+
await drainSandboxBeforeTermination(sandbox, scope);
|
|
1943
|
+
await terminateAtProvider(scope, "sandbox.delete", () => sandbox.delete(toTimeoutSeconds(config.timeoutMs), true));
|
|
1944
|
+
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
|
1945
|
+
}
|
|
1946
|
+
finally {
|
|
1947
|
+
sandboxHandleTeardownGates.end(scope, teardownGate);
|
|
1948
|
+
evictSandboxHandle(scope);
|
|
1949
|
+
}
|
|
1950
|
+
},
|
|
1951
|
+
async onEnvironmentRealizeWorkspace(params) {
|
|
1952
|
+
const config = parseDriverConfig(params.config);
|
|
1953
|
+
const remoteCwd = typeof params.lease.metadata?.remoteCwd === "string" &&
|
|
1954
|
+
params.lease.metadata.remoteCwd.trim().length > 0
|
|
1955
|
+
? params.lease.metadata.remoteCwd.trim()
|
|
1956
|
+
: params.workspace.remotePath ?? params.workspace.localPath ?? "/paperclip-workspace";
|
|
1957
|
+
if (params.lease.providerLeaseId) {
|
|
1958
|
+
const scope = {
|
|
1959
|
+
driverKey: params.driverKey,
|
|
1960
|
+
companyId: params.companyId,
|
|
1961
|
+
environmentId: params.environmentId,
|
|
1962
|
+
providerLeaseId: params.lease.providerLeaseId,
|
|
1963
|
+
config,
|
|
1964
|
+
};
|
|
1965
|
+
await withSandboxActivityGate(scope, async () => {
|
|
1966
|
+
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
|
|
1967
|
+
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
1968
|
+
await sandbox.fs.createFolder(remoteCwd, "755");
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
return {
|
|
1972
|
+
cwd: remoteCwd,
|
|
1973
|
+
metadata: {
|
|
1974
|
+
provider: "daytona",
|
|
1975
|
+
remoteCwd,
|
|
1976
|
+
},
|
|
1977
|
+
};
|
|
1978
|
+
},
|
|
1979
|
+
async onEnvironmentStartInteractiveSetup(params) {
|
|
1980
|
+
const baseConfig = parseDriverConfig(params.config);
|
|
1981
|
+
const config = withSetupSourceTemplate(baseConfig, params);
|
|
1982
|
+
const sandbox = await createSandbox(params, config, { purpose: "interactive_setup" });
|
|
1983
|
+
try {
|
|
1984
|
+
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
|
1985
|
+
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
1986
|
+
const connection = await createSshConnection(sandbox, resolveConnectionExpiresInMinutes(params.connectionExpiresInMinutes));
|
|
1987
|
+
sandboxHandleLeaseAdmissionStates.open({
|
|
1988
|
+
driverKey: params.driverKey,
|
|
1989
|
+
companyId: params.companyId,
|
|
1990
|
+
environmentId: params.environmentId,
|
|
1991
|
+
providerLeaseId: sandbox.id,
|
|
1992
|
+
config,
|
|
1993
|
+
});
|
|
1994
|
+
return {
|
|
1995
|
+
providerLeaseId: sandbox.id,
|
|
1996
|
+
status: "waiting_for_user",
|
|
1997
|
+
expiresAt: params.expiresAt ?? connection.connectionPayload?.expiresAt ?? null,
|
|
1998
|
+
...connection,
|
|
1999
|
+
metadata: interactiveSetupMetadata({
|
|
2000
|
+
config,
|
|
2001
|
+
sandbox,
|
|
2002
|
+
shellCommand,
|
|
2003
|
+
remoteCwd,
|
|
2004
|
+
sourceTemplateRef: params.sourceTemplateRef,
|
|
2005
|
+
}),
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
catch (error) {
|
|
2009
|
+
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
|
|
2010
|
+
throw error;
|
|
2011
|
+
}
|
|
2012
|
+
},
|
|
2013
|
+
async onEnvironmentGetInteractiveSetup(params) {
|
|
2014
|
+
const config = parseDriverConfig(params.config);
|
|
2015
|
+
if (!params.providerLeaseId) {
|
|
2016
|
+
return {
|
|
2017
|
+
providerLeaseId: null,
|
|
2018
|
+
status: "missing",
|
|
2019
|
+
connectionSummary: null,
|
|
2020
|
+
connectionPayload: null,
|
|
2021
|
+
metadata: {
|
|
2022
|
+
provider: "daytona",
|
|
2023
|
+
missing: true,
|
|
2024
|
+
},
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
const scope = {
|
|
2028
|
+
driverKey: params.driverKey,
|
|
2029
|
+
companyId: params.companyId,
|
|
2030
|
+
environmentId: params.environmentId,
|
|
2031
|
+
providerLeaseId: params.providerLeaseId,
|
|
2032
|
+
config,
|
|
2033
|
+
};
|
|
2034
|
+
return await withSandboxActivityGate(scope, async () => {
|
|
2035
|
+
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
|
2036
|
+
if (!sandbox) {
|
|
2037
|
+
return {
|
|
2038
|
+
providerLeaseId: null,
|
|
2039
|
+
status: "missing",
|
|
2040
|
+
connectionSummary: null,
|
|
2041
|
+
connectionPayload: null,
|
|
2042
|
+
metadata: {
|
|
2043
|
+
provider: "daytona",
|
|
2044
|
+
missing: true,
|
|
2045
|
+
},
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
2049
|
+
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
|
2050
|
+
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
2051
|
+
const connection = params.includeConnectionPayload === true
|
|
2052
|
+
? await createSshConnection(sandbox, resolveConnectionExpiresInMinutes(params.connectionExpiresInMinutes))
|
|
2053
|
+
: {
|
|
2054
|
+
connectionSummary: {
|
|
2055
|
+
type: "ssh",
|
|
2056
|
+
username: "token",
|
|
2057
|
+
hostRedacted: true,
|
|
2058
|
+
portRedacted: true,
|
|
2059
|
+
commandRedacted: true,
|
|
2060
|
+
metadata: {
|
|
2061
|
+
provider: "daytona",
|
|
2062
|
+
},
|
|
2063
|
+
},
|
|
2064
|
+
connectionPayload: null,
|
|
2065
|
+
};
|
|
2066
|
+
return {
|
|
2067
|
+
providerLeaseId: sandbox.id,
|
|
2068
|
+
status: "waiting_for_user",
|
|
2069
|
+
...connection,
|
|
2070
|
+
metadata: interactiveSetupMetadata({
|
|
2071
|
+
config,
|
|
2072
|
+
sandbox,
|
|
2073
|
+
shellCommand,
|
|
2074
|
+
remoteCwd,
|
|
2075
|
+
}),
|
|
2076
|
+
};
|
|
2077
|
+
});
|
|
2078
|
+
},
|
|
2079
|
+
async onEnvironmentCaptureTemplate(params) {
|
|
2080
|
+
const config = parseDriverConfig(params.config);
|
|
2081
|
+
if (!params.providerLeaseId) {
|
|
2082
|
+
throw new Error("Cannot capture a Daytona template without a setup sandbox lease.");
|
|
2083
|
+
}
|
|
2084
|
+
const scope = {
|
|
2085
|
+
driverKey: params.driverKey,
|
|
2086
|
+
companyId: params.companyId,
|
|
2087
|
+
environmentId: params.environmentId,
|
|
2088
|
+
providerLeaseId: params.providerLeaseId,
|
|
2089
|
+
config,
|
|
2090
|
+
};
|
|
2091
|
+
return await withSandboxActivityGate(scope, async () => {
|
|
2092
|
+
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
|
|
2093
|
+
const createSnapshot = sandbox._experimental_createSnapshot;
|
|
2094
|
+
if (typeof createSnapshot !== "function") {
|
|
2095
|
+
throw new Error("Daytona template capture requires @daytonaio/sdk Sandbox._experimental_createSnapshot support.");
|
|
2096
|
+
}
|
|
2097
|
+
const templateRef = sanitizeSnapshotName(params.templateLabel, `paperclip-${params.environmentId}-${randomUUID().slice(0, 8)}`);
|
|
2098
|
+
const timeoutMs = typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
|
|
2099
|
+
? Math.trunc(params.timeoutMs)
|
|
2100
|
+
: config.timeoutMs;
|
|
2101
|
+
await createSnapshot.call(sandbox, templateRef, toTimeoutSeconds(timeoutMs));
|
|
2102
|
+
return {
|
|
2103
|
+
templateKind: "snapshot",
|
|
2104
|
+
templateRef,
|
|
2105
|
+
metadata: {
|
|
2106
|
+
provider: "daytona",
|
|
2107
|
+
sandboxId: sandbox.id,
|
|
2108
|
+
capturedAt: new Date().toISOString(),
|
|
2109
|
+
sourceTemplateRefRedacted: Boolean(params.sourceTemplateRef),
|
|
2110
|
+
previousTemplateRefRedacted: Boolean(params.previousTemplateRef),
|
|
2111
|
+
timeoutMs,
|
|
2112
|
+
},
|
|
2113
|
+
};
|
|
2114
|
+
});
|
|
2115
|
+
},
|
|
2116
|
+
async onEnvironmentCancelInteractiveSetup(params) {
|
|
2117
|
+
const config = parseDriverConfig(params.config);
|
|
2118
|
+
if (!params.providerLeaseId) {
|
|
2119
|
+
return {
|
|
2120
|
+
status: "missing",
|
|
2121
|
+
metadata: {
|
|
2122
|
+
provider: "daytona",
|
|
2123
|
+
missing: true,
|
|
2124
|
+
reason: params.reason ?? null,
|
|
2125
|
+
},
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
const scope = {
|
|
2129
|
+
driverKey: params.driverKey,
|
|
2130
|
+
companyId: params.companyId,
|
|
2131
|
+
environmentId: params.environmentId,
|
|
2132
|
+
providerLeaseId: params.providerLeaseId,
|
|
2133
|
+
config,
|
|
2134
|
+
};
|
|
2135
|
+
// C4: cancelling an interactive-setup lease deletes the sandbox, so the
|
|
2136
|
+
// teardown gate blocks fresh cache reads while delete is in flight.
|
|
2137
|
+
const teardownGate = sandboxHandleTeardownGates.begin(scope);
|
|
2138
|
+
sandboxHandleLeaseAdmissionStates.close(scope);
|
|
2139
|
+
try {
|
|
2140
|
+
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
|
2141
|
+
if (!sandbox) {
|
|
2142
|
+
return {
|
|
2143
|
+
status: "missing",
|
|
2144
|
+
metadata: {
|
|
2145
|
+
provider: "daytona",
|
|
2146
|
+
missing: true,
|
|
2147
|
+
reason: params.reason ?? null,
|
|
2148
|
+
},
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
evictSandboxHandle(scope);
|
|
2152
|
+
await sandboxHandleActivityGates.waitForIdle(scope);
|
|
2153
|
+
await teardownSession(sandbox, scope);
|
|
2154
|
+
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
|
|
2155
|
+
return {
|
|
2156
|
+
status: params.reason === "timed_out" ? "timed_out" : "cancelled",
|
|
2157
|
+
metadata: {
|
|
2158
|
+
provider: "daytona",
|
|
2159
|
+
sandboxId: sandbox.id,
|
|
2160
|
+
reason: params.reason ?? null,
|
|
2161
|
+
},
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
finally {
|
|
2165
|
+
sandboxHandleTeardownGates.end(scope, teardownGate);
|
|
2166
|
+
evictSandboxHandle(scope);
|
|
2167
|
+
}
|
|
2168
|
+
},
|
|
2169
|
+
async onEnvironmentDeleteTemplate(params) {
|
|
2170
|
+
const templateKind = params.templateKind ?? "snapshot";
|
|
2171
|
+
if (templateKind !== "snapshot") {
|
|
2172
|
+
throw new Error(`Daytona can delete snapshot templates only, not ${templateKind}.`);
|
|
2173
|
+
}
|
|
2174
|
+
const config = parseDriverConfig(params.config);
|
|
2175
|
+
const client = createDaytonaClient(config);
|
|
2176
|
+
const snapshotService = client.snapshot;
|
|
2177
|
+
if (typeof snapshotService?.get !== "function" || typeof snapshotService.delete !== "function") {
|
|
2178
|
+
throw new Error("Daytona template deletion requires @daytonaio/sdk snapshot.get/delete support.");
|
|
2179
|
+
}
|
|
2180
|
+
const snapshot = await snapshotService.get(params.templateRef);
|
|
2181
|
+
await snapshotService.delete(snapshot);
|
|
2182
|
+
return {
|
|
2183
|
+
deleted: true,
|
|
2184
|
+
metadata: {
|
|
2185
|
+
provider: "daytona",
|
|
2186
|
+
templateKind: "snapshot",
|
|
2187
|
+
templateRefRedacted: true,
|
|
2188
|
+
reason: params.reason ?? null,
|
|
2189
|
+
},
|
|
2190
|
+
};
|
|
2191
|
+
},
|
|
2192
|
+
async onEnvironmentExecute(params) {
|
|
2193
|
+
if (!params.lease.providerLeaseId) {
|
|
2194
|
+
return {
|
|
2195
|
+
exitCode: 1,
|
|
2196
|
+
timedOut: false,
|
|
2197
|
+
stdout: "",
|
|
2198
|
+
stderr: "No provider lease ID available for execution.",
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
const config = parseDriverConfig(params.config);
|
|
2202
|
+
const providerLeaseId = params.lease.providerLeaseId;
|
|
2203
|
+
return await withSandboxActivityGate({
|
|
2204
|
+
driverKey: params.driverKey,
|
|
2205
|
+
companyId: params.companyId,
|
|
2206
|
+
environmentId: params.environmentId,
|
|
2207
|
+
providerLeaseId,
|
|
2208
|
+
config,
|
|
2209
|
+
}, async () => {
|
|
2210
|
+
// Time the sandbox handle lookup (Open Q1) separately from the
|
|
2211
|
+
// `executeCommand` round-trip so telemetry can split the per-call get cost
|
|
2212
|
+
// from the exec cost. With the per-lease handle cache this collapses to ~0
|
|
2213
|
+
// on a hit (no `client.get` REST round-trip), but the field stays present so
|
|
2214
|
+
// `providerGetMs` remains observable — and still captures the occasional
|
|
2215
|
+
// freshness refresh the cache issues after an idle gap. `ensureSandboxStarted`
|
|
2216
|
+
// is a no-op for an already-started sandbox, so it is excluded from the get
|
|
2217
|
+
// measurement.
|
|
2218
|
+
const getStart = timingNow();
|
|
2219
|
+
// Decide the explicit `cache_hit` flag at the true cache decision: the
|
|
2220
|
+
// handle lookup reports whether the warm cache served the handle or the
|
|
2221
|
+
// lookup called `client.get`. This replaces the old `providerGetMs == 0`
|
|
2222
|
+
// proxy. The default `false` covers the theoretical case where the lookup
|
|
2223
|
+
// reports nothing.
|
|
2224
|
+
let cacheHit = false;
|
|
2225
|
+
const sandbox = await getSandbox({
|
|
2226
|
+
driverKey: params.driverKey,
|
|
2227
|
+
companyId: params.companyId,
|
|
2228
|
+
environmentId: params.environmentId,
|
|
2229
|
+
providerLeaseId,
|
|
2230
|
+
config,
|
|
2231
|
+
}, {
|
|
2232
|
+
bypassTeardownGate: true,
|
|
2233
|
+
onCacheDecision: (hit) => {
|
|
2234
|
+
cacheHit = hit;
|
|
2235
|
+
},
|
|
2236
|
+
});
|
|
2237
|
+
const getDurationMs = timingNow() - getStart;
|
|
2238
|
+
const scope = {
|
|
2239
|
+
driverKey: params.driverKey,
|
|
2240
|
+
companyId: params.companyId,
|
|
2241
|
+
environmentId: params.environmentId,
|
|
2242
|
+
providerLeaseId,
|
|
2243
|
+
config,
|
|
2244
|
+
};
|
|
2245
|
+
if (sandbox.state !== "started") {
|
|
2246
|
+
// A provider restart destroys Daytona process sessions. Drop the stale
|
|
2247
|
+
// session id before starting the sandbox so runnerd recovery opens a
|
|
2248
|
+
// new session instead of retrying a dead one for its whole grace.
|
|
2249
|
+
sandboxHandleSessionStore.clear(scope);
|
|
2250
|
+
}
|
|
2251
|
+
await ensureSandboxStarted(sandbox, toTimeoutSeconds(resolveTimeoutMs(params.timeoutMs, config)));
|
|
2252
|
+
// Dispatch the command. A normal command runs in the persistent session:
|
|
2253
|
+
// the provider opens the one session on a cache miss and runs every command
|
|
2254
|
+
// in it. The provider never falls back to a one-shot command to open a
|
|
2255
|
+
// session; a cache miss creates one.
|
|
2256
|
+
//
|
|
2257
|
+
// A `bypassSession` command runs one-shot and does NOT open the session.
|
|
2258
|
+
// The host sets this flag on a pre-run command (the workspace provision
|
|
2259
|
+
// command) that runs before the run opens its trace root. Opening the
|
|
2260
|
+
// session there would emit a `session.open` span with no run parent, and
|
|
2261
|
+
// the span backend would drop it. With the bypass the session opens on the
|
|
2262
|
+
// first in-run command, whose open span parents to the run trace.
|
|
2263
|
+
let result;
|
|
2264
|
+
if (!params.bypassSession) {
|
|
2265
|
+
const sessionId = await getOrCreateSession(sandbox, scope);
|
|
2266
|
+
result = await executeInSession(sandbox, sessionId, params, config);
|
|
2267
|
+
}
|
|
2268
|
+
else {
|
|
2269
|
+
result = await executeOneShot(sandbox, params, config);
|
|
2270
|
+
}
|
|
2271
|
+
if (!result.timedOut) {
|
|
2272
|
+
sandboxHandleCache.markFresh(scope);
|
|
2273
|
+
}
|
|
2274
|
+
return {
|
|
2275
|
+
...result,
|
|
2276
|
+
metadata: { ...(result.metadata ?? {}), getDurationMs, cacheHit },
|
|
2277
|
+
};
|
|
2278
|
+
});
|
|
2279
|
+
},
|
|
2280
|
+
async onEnvironmentRunnerIngressEndpoint(params) {
|
|
2281
|
+
if (params.port !== 43_127) {
|
|
2282
|
+
throw new Error("Daytona runner ingress must use fixed port 43127.");
|
|
2283
|
+
}
|
|
2284
|
+
if (!/^\/api\/runner\/v1\/connect\/[^/?#]+$/.test(params.path)) {
|
|
2285
|
+
throw new Error("Daytona runner ingress path is invalid.");
|
|
2286
|
+
}
|
|
2287
|
+
const providerLeaseId = params.lease.providerLeaseId;
|
|
2288
|
+
if (!providerLeaseId) {
|
|
2289
|
+
throw new Error("Daytona runner ingress requires a provider lease id.");
|
|
2290
|
+
}
|
|
2291
|
+
const config = parseDriverConfig(params.config);
|
|
2292
|
+
return await withSandboxActivityGate({
|
|
2293
|
+
driverKey: params.driverKey,
|
|
2294
|
+
companyId: params.companyId,
|
|
2295
|
+
environmentId: params.environmentId,
|
|
2296
|
+
providerLeaseId,
|
|
2297
|
+
config,
|
|
2298
|
+
}, async () => {
|
|
2299
|
+
const sandbox = await getSandbox({
|
|
2300
|
+
driverKey: params.driverKey,
|
|
2301
|
+
companyId: params.companyId,
|
|
2302
|
+
environmentId: params.environmentId,
|
|
2303
|
+
providerLeaseId,
|
|
2304
|
+
config,
|
|
2305
|
+
});
|
|
2306
|
+
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
|
|
2307
|
+
await withLivenessTimeout("sandbox.refreshData", config.livenessTimeoutMs, () => sandbox.refreshData());
|
|
2308
|
+
const preview = await sandbox.getPreviewLink(params.port);
|
|
2309
|
+
if (typeof preview.url !== "string" || typeof preview.token !== "string") {
|
|
2310
|
+
throw new Error("Daytona returned an incomplete private preview endpoint.");
|
|
2311
|
+
}
|
|
2312
|
+
const url = new URL(preview.url);
|
|
2313
|
+
if (url.protocol !== "https:" ||
|
|
2314
|
+
url.username ||
|
|
2315
|
+
url.password ||
|
|
2316
|
+
url.search ||
|
|
2317
|
+
url.hash) {
|
|
2318
|
+
throw new Error("Daytona returned an invalid private preview URL.");
|
|
2319
|
+
}
|
|
2320
|
+
url.protocol = "wss:";
|
|
2321
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}${params.path}`;
|
|
2322
|
+
return {
|
|
2323
|
+
kind: "authenticated_websocket",
|
|
2324
|
+
websocketUrl: url.toString(),
|
|
2325
|
+
secretHeaders: [
|
|
2326
|
+
{ name: "X-Daytona-Preview-Token", value: preview.token },
|
|
2327
|
+
],
|
|
2328
|
+
generation: runnerIngressGenerationStore.get(sandbox),
|
|
2329
|
+
};
|
|
2330
|
+
});
|
|
2331
|
+
},
|
|
2332
|
+
// Opt-in native inbound transfer. Defining this hook (with onEnvironmentSyncOut)
|
|
2333
|
+
// makes the worker advertise `environmentSyncIn`/`environmentSyncOut`, so the
|
|
2334
|
+
// host runner routes Daytona workspace/asset transfers through the SDK's batch
|
|
2335
|
+
// `uploadFiles` (plus host-side tarballs for directories) instead of the
|
|
2336
|
+
// base64-over-exec fallback. Providers that do not define these keep the
|
|
2337
|
+
// byte-identical fallback.
|
|
2338
|
+
async onEnvironmentSyncIn(params) {
|
|
2339
|
+
if (!params.lease.providerLeaseId) {
|
|
2340
|
+
throw new Error("Daytona syncIn requires a provider lease ID.");
|
|
2341
|
+
}
|
|
2342
|
+
const config = parseDriverConfig(params.config);
|
|
2343
|
+
const remoteDir = resolveSyncRemoteDir(params.lease);
|
|
2344
|
+
const timeoutSeconds = toTimeoutSeconds(config.timeoutMs);
|
|
2345
|
+
const scope = {
|
|
2346
|
+
driverKey: params.driverKey,
|
|
2347
|
+
companyId: params.companyId,
|
|
2348
|
+
environmentId: params.environmentId,
|
|
2349
|
+
providerLeaseId: params.lease.providerLeaseId,
|
|
2350
|
+
config,
|
|
2351
|
+
};
|
|
2352
|
+
// Collect the advisory read-write destinations for this scope. This records
|
|
2353
|
+
// intent only; it does not change the transfer below.
|
|
2354
|
+
sandboxHandleWritableDirs.recordWritableTargets(scope, params.operations);
|
|
2355
|
+
return await withSandboxActivityGate(scope, async () => {
|
|
2356
|
+
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
|
|
2357
|
+
await ensureSandboxStarted(sandbox, timeoutSeconds);
|
|
2358
|
+
const result = await performSyncIn({
|
|
2359
|
+
sandbox,
|
|
2360
|
+
operations: params.operations,
|
|
2361
|
+
remoteDir,
|
|
2362
|
+
timeoutSeconds,
|
|
2363
|
+
});
|
|
2364
|
+
sandboxHandleCache.markFresh(scope);
|
|
2365
|
+
return result;
|
|
2366
|
+
});
|
|
2367
|
+
},
|
|
2368
|
+
// Opt-in native outbound transfer. See onEnvironmentSyncIn.
|
|
2369
|
+
async onEnvironmentSyncOut(params) {
|
|
2370
|
+
if (!params.lease.providerLeaseId) {
|
|
2371
|
+
throw new Error("Daytona syncOut requires a provider lease ID.");
|
|
2372
|
+
}
|
|
2373
|
+
const config = parseDriverConfig(params.config);
|
|
2374
|
+
const remoteDir = resolveSyncRemoteDir(params.lease);
|
|
2375
|
+
const timeoutSeconds = toTimeoutSeconds(config.timeoutMs);
|
|
2376
|
+
const scope = {
|
|
2377
|
+
driverKey: params.driverKey,
|
|
2378
|
+
companyId: params.companyId,
|
|
2379
|
+
environmentId: params.environmentId,
|
|
2380
|
+
providerLeaseId: params.lease.providerLeaseId,
|
|
2381
|
+
config,
|
|
2382
|
+
};
|
|
2383
|
+
try {
|
|
2384
|
+
return await withSandboxActivityGate(scope, async () => {
|
|
2385
|
+
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
|
|
2386
|
+
await ensureSandboxStarted(sandbox, timeoutSeconds);
|
|
2387
|
+
const result = await performSyncOut({
|
|
2388
|
+
sandbox,
|
|
2389
|
+
operations: params.operations,
|
|
2390
|
+
remoteDir,
|
|
2391
|
+
timeoutSeconds,
|
|
2392
|
+
});
|
|
2393
|
+
sandboxHandleCache.markFresh(scope);
|
|
2394
|
+
return result;
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
2397
|
+
catch (error) {
|
|
2398
|
+
// A deleted Daytona sandbox is the one provider failure that proves its
|
|
2399
|
+
// unexported workspace bytes no longer exist. Convert the SDK class to a
|
|
2400
|
+
// stable cross-worker message; every other error remains retryable.
|
|
2401
|
+
if (error instanceof DaytonaNotFoundError) {
|
|
2402
|
+
throw new Error("daytona_sandbox_not_found");
|
|
2403
|
+
}
|
|
2404
|
+
throw error;
|
|
2405
|
+
}
|
|
2406
|
+
},
|
|
2407
|
+
// Open one live login pseudo-terminal. Resolve the cached sandbox by the
|
|
2408
|
+
// provider lease id, revalidate the host launch descriptor, create the session
|
|
2409
|
+
// home with one `mkdir -p` command, run the fixed login command on a real
|
|
2410
|
+
// pseudo-terminal, and register the session under the host route id. Stream the
|
|
2411
|
+
// raw output and the exit through `ctx.loginPty`, bound to the returned worker
|
|
2412
|
+
// session id. Fail closed when no cached sandbox matches the lease.
|
|
2413
|
+
async onLoginPtyOpen(params) {
|
|
2414
|
+
const sandbox = await sandboxHandleCache.findByProviderLeaseId(params.providerLeaseId);
|
|
2415
|
+
if (!sandbox) {
|
|
2416
|
+
throw new Error("Daytona login pseudo-terminal: no cached sandbox resolves the provider lease.");
|
|
2417
|
+
}
|
|
2418
|
+
const homeFs = createDaytonaLoginHomeFs(sandbox.process);
|
|
2419
|
+
const session = await openLoginPtySession(sandbox.process, homeFs, { loginCommandKey: params.loginCommandKey, sessionHome: params.sessionHome });
|
|
2420
|
+
const workerSessionId = `pty-${randomUUID()}`;
|
|
2421
|
+
const entry = {
|
|
2422
|
+
hostRouteId: params.hostRouteId,
|
|
2423
|
+
workerSessionId,
|
|
2424
|
+
session,
|
|
2425
|
+
};
|
|
2426
|
+
daytonaLoginPtyByRoute.set(params.hostRouteId, entry);
|
|
2427
|
+
daytonaLoginPtyBySession.set(workerSessionId, entry);
|
|
2428
|
+
// Register the output listener before the first input, so no early output
|
|
2429
|
+
// chunk is lost. The client stamps the host route identifier and the worker
|
|
2430
|
+
// session identifier, so the host can hold more than one concurrent login
|
|
2431
|
+
// pseudo-terminal on this worker and binds the output to its own route.
|
|
2432
|
+
session.onData((chunk) => {
|
|
2433
|
+
pluginContext?.loginPty.output(params.hostRouteId, workerSessionId, chunk);
|
|
2434
|
+
});
|
|
2435
|
+
// Forward the child exit one time. The host resolves the login run on it.
|
|
2436
|
+
void session.wait().then((result) => pluginContext?.loginPty.exit(params.hostRouteId, workerSessionId, result.exitCode), () => pluginContext?.loginPty.exit(params.hostRouteId, workerSessionId, null));
|
|
2437
|
+
return { workerSessionId };
|
|
2438
|
+
},
|
|
2439
|
+
// Write delayed input to an open login pseudo-terminal, keyed by the worker
|
|
2440
|
+
// session id. Drop the input for an unknown session.
|
|
2441
|
+
async onLoginPtyInput(params) {
|
|
2442
|
+
const entry = daytonaLoginPtyBySession.get(params.workerSessionId);
|
|
2443
|
+
if (!entry)
|
|
2444
|
+
return;
|
|
2445
|
+
entry.session.write(params.data);
|
|
2446
|
+
},
|
|
2447
|
+
// Stop an open login pseudo-terminal child, keyed by the worker session id.
|
|
2448
|
+
async onLoginPtyStop(params) {
|
|
2449
|
+
const entry = daytonaLoginPtyBySession.get(params.workerSessionId);
|
|
2450
|
+
if (!entry)
|
|
2451
|
+
return;
|
|
2452
|
+
entry.session.kill();
|
|
2453
|
+
},
|
|
2454
|
+
// Close an open login pseudo-terminal by the host route id and acknowledge the
|
|
2455
|
+
// close with the same identifier. The close is idempotent: it returns the
|
|
2456
|
+
// acknowledgement even when the entry is already gone, so the host confirms the
|
|
2457
|
+
// terminal is closed. The worker never keys the close on the worker session id.
|
|
2458
|
+
async onLoginPtyClose(params) {
|
|
2459
|
+
const entry = daytonaLoginPtyByRoute.get(params.hostRouteId);
|
|
2460
|
+
if (entry) {
|
|
2461
|
+
forgetDaytonaLoginPty(entry);
|
|
2462
|
+
await entry.session.close().catch(() => undefined);
|
|
2463
|
+
}
|
|
2464
|
+
return { hostRouteId: params.hostRouteId };
|
|
2465
|
+
},
|
|
2466
|
+
// Open one persistent duplex channel. Resolve the cached sandbox by the provider
|
|
2467
|
+
// lease id, run the gateway command on a raw pseudo-terminal, and register the
|
|
2468
|
+
// channel under the host route id. Stream the raw data and the exit through
|
|
2469
|
+
// `ctx.duplexChannel`, bound to the returned worker session id. Fail closed when
|
|
2470
|
+
// no cached sandbox matches the lease.
|
|
2471
|
+
async onDuplexChannelOpen(params) {
|
|
2472
|
+
const sandbox = await sandboxHandleCache.findByProviderLeaseId(params.providerLeaseId);
|
|
2473
|
+
if (!sandbox) {
|
|
2474
|
+
throw new Error("Daytona duplex channel: no cached sandbox resolves the provider lease.");
|
|
2475
|
+
}
|
|
2476
|
+
const session = await openDuplexChannelSession(sandbox.process, params.command);
|
|
2477
|
+
const workerSessionId = `duplex-${randomUUID()}`;
|
|
2478
|
+
const entry = {
|
|
2479
|
+
hostRouteId: params.hostRouteId,
|
|
2480
|
+
workerSessionId,
|
|
2481
|
+
providerLeaseId: params.providerLeaseId,
|
|
2482
|
+
session,
|
|
2483
|
+
};
|
|
2484
|
+
daytonaDuplexChannelByRoute.set(params.hostRouteId, entry);
|
|
2485
|
+
daytonaDuplexChannelBySession.set(workerSessionId, entry);
|
|
2486
|
+
// Register the data listener before the first write, so no early data chunk is
|
|
2487
|
+
// lost. The client echoes the host route id and the worker session id, so the
|
|
2488
|
+
// host routes the data to the exact live pair.
|
|
2489
|
+
session.onData((chunk) => {
|
|
2490
|
+
pluginContext?.duplexChannel.data(entry.hostRouteId, workerSessionId, chunk);
|
|
2491
|
+
});
|
|
2492
|
+
// Forward the child exit one time. The host resolves the open route on it. A
|
|
2493
|
+
// numeric exit code is a real process exit; a resolved transport close carries
|
|
2494
|
+
// `transportClosed`, so the host keeps the two apart in the loss taxonomy. A
|
|
2495
|
+
// rejected wait is not a resolved transport close, so it reports no code and no
|
|
2496
|
+
// transport-close mark.
|
|
2497
|
+
void session.wait().then((result) => pluginContext?.duplexChannel.exit(entry.hostRouteId, workerSessionId, result.exitCode, result.transportClosed), () => pluginContext?.duplexChannel.exit(entry.hostRouteId, workerSessionId, null));
|
|
2498
|
+
// Echo the host route id on the reply, so the host binds the exact pair.
|
|
2499
|
+
return { hostRouteId: params.hostRouteId, workerSessionId };
|
|
2500
|
+
},
|
|
2501
|
+
// Write host input to an open duplex channel. Act only on the exact live pair.
|
|
2502
|
+
// A write whose pair does not match the bound entry applies no bytes.
|
|
2503
|
+
//
|
|
2504
|
+
// `params.data` arrives in the wire-safe base64 form (JSON carries no binary
|
|
2505
|
+
// type; see `ChannelBytesWireValue` in the plugin SDK's protocol.ts). Decode it
|
|
2506
|
+
// back to raw bytes before it reaches the pseudo-terminal. A malformed value
|
|
2507
|
+
// decodes to `null`; the worker applies no bytes rather than sending an empty
|
|
2508
|
+
// write to the sandbox.
|
|
2509
|
+
async onDuplexChannelWrite(params) {
|
|
2510
|
+
const entry = daytonaDuplexChannelBySession.get(params.workerSessionId);
|
|
2511
|
+
if (!entry || entry.hostRouteId !== params.hostRouteId)
|
|
2512
|
+
return;
|
|
2513
|
+
const data = decodeChannelBytes(params.data);
|
|
2514
|
+
if (data === null)
|
|
2515
|
+
return;
|
|
2516
|
+
entry.session.write(data);
|
|
2517
|
+
},
|
|
2518
|
+
// Stop an open duplex channel child. Act only on the exact live pair. A stop
|
|
2519
|
+
// whose pair does not match the bound entry stops nothing.
|
|
2520
|
+
async onDuplexChannelStop(params) {
|
|
2521
|
+
const entry = daytonaDuplexChannelBySession.get(params.workerSessionId);
|
|
2522
|
+
if (!entry || entry.hostRouteId !== params.hostRouteId)
|
|
2523
|
+
return;
|
|
2524
|
+
entry.session.kill();
|
|
2525
|
+
},
|
|
2526
|
+
// Close an open duplex channel by the host route id and acknowledge the close.
|
|
2527
|
+
// The host route id is the authoritative close key, so a pre-bind close with a
|
|
2528
|
+
// lost open reply still closes the channel. On a bound close the acknowledgement
|
|
2529
|
+
// echoes the worker session id too, so the host verifies the exact pair. The
|
|
2530
|
+
// close is idempotent: it returns the acknowledgement even when the entry is
|
|
2531
|
+
// already gone, so the host confirms the channel is closed.
|
|
2532
|
+
async onDuplexChannelClose(params) {
|
|
2533
|
+
const entry = daytonaDuplexChannelByRoute.get(params.hostRouteId);
|
|
2534
|
+
if (entry) {
|
|
2535
|
+
const boundWorkerSessionId = entry.workerSessionId;
|
|
2536
|
+
forgetDaytonaDuplexChannel(entry);
|
|
2537
|
+
await entry.session.close().catch(() => undefined);
|
|
2538
|
+
return { hostRouteId: params.hostRouteId, workerSessionId: boundWorkerSessionId };
|
|
2539
|
+
}
|
|
2540
|
+
return { hostRouteId: params.hostRouteId };
|
|
2541
|
+
},
|
|
2542
|
+
// Close every open login pseudo-terminal and every open duplex channel on an
|
|
2543
|
+
// orderly shutdown, then drain the sandbox handle cache, so a graceful shutdown
|
|
2544
|
+
// holds no live terminal and no live channel.
|
|
2545
|
+
async onShutdown() {
|
|
2546
|
+
const openSessions = [...daytonaLoginPtyByRoute.values()];
|
|
2547
|
+
daytonaLoginPtyByRoute.clear();
|
|
2548
|
+
daytonaLoginPtyBySession.clear();
|
|
2549
|
+
for (const entry of openSessions) {
|
|
2550
|
+
await entry.session.close().catch(() => undefined);
|
|
2551
|
+
}
|
|
2552
|
+
const openChannels = [...daytonaDuplexChannelByRoute.values()];
|
|
2553
|
+
daytonaDuplexChannelByRoute.clear();
|
|
2554
|
+
daytonaDuplexChannelBySession.clear();
|
|
2555
|
+
for (const entry of openChannels) {
|
|
2556
|
+
await entry.session.close().catch(() => undefined);
|
|
2557
|
+
}
|
|
2558
|
+
sandboxHandleCache.reset();
|
|
2559
|
+
runnerIngressGenerationStore.reset();
|
|
2560
|
+
},
|
|
2561
|
+
});
|
|
2562
|
+
export default plugin;
|
|
2563
|
+
//# sourceMappingURL=plugin.js.map
|