@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.
Files changed (58) hide show
  1. package/README.md +49 -0
  2. package/dist/duplex-command-stream.d.ts +97 -0
  3. package/dist/duplex-command-stream.d.ts.map +1 -0
  4. package/dist/duplex-command-stream.js +205 -0
  5. package/dist/duplex-command-stream.js.map +1 -0
  6. package/dist/duplex-command-stream.live.test.d.ts +2 -0
  7. package/dist/duplex-command-stream.live.test.d.ts.map +1 -0
  8. package/dist/duplex-command-stream.live.test.js +324 -0
  9. package/dist/duplex-command-stream.live.test.js.map +1 -0
  10. package/dist/duplex-command-stream.test.d.ts +2 -0
  11. package/dist/duplex-command-stream.test.d.ts.map +1 -0
  12. package/dist/duplex-command-stream.test.js +519 -0
  13. package/dist/duplex-command-stream.test.js.map +1 -0
  14. package/dist/file-sync.d.ts +77 -0
  15. package/dist/file-sync.d.ts.map +1 -0
  16. package/dist/file-sync.js +1055 -0
  17. package/dist/file-sync.js.map +1 -0
  18. package/dist/file-sync.test.d.ts +2 -0
  19. package/dist/file-sync.test.d.ts.map +1 -0
  20. package/dist/file-sync.test.js +974 -0
  21. package/dist/file-sync.test.js.map +1 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/login-pty.d.ts +162 -0
  27. package/dist/login-pty.d.ts.map +1 -0
  28. package/dist/login-pty.js +258 -0
  29. package/dist/login-pty.js.map +1 -0
  30. package/dist/login-pty.test.d.ts +2 -0
  31. package/dist/login-pty.test.d.ts.map +1 -0
  32. package/dist/login-pty.test.js +319 -0
  33. package/dist/login-pty.test.js.map +1 -0
  34. package/dist/manifest.d.ts +4 -0
  35. package/dist/manifest.d.ts.map +1 -0
  36. package/dist/manifest.js +179 -0
  37. package/dist/manifest.js.map +1 -0
  38. package/dist/plugin.d.ts +49 -0
  39. package/dist/plugin.d.ts.map +1 -0
  40. package/dist/plugin.js +2563 -0
  41. package/dist/plugin.js.map +1 -0
  42. package/dist/plugin.test.d.ts +2 -0
  43. package/dist/plugin.test.d.ts.map +1 -0
  44. package/dist/plugin.test.js +4701 -0
  45. package/dist/plugin.test.js.map +1 -0
  46. package/dist/pty-chunked-input.d.ts +48 -0
  47. package/dist/pty-chunked-input.d.ts.map +1 -0
  48. package/dist/pty-chunked-input.js +74 -0
  49. package/dist/pty-chunked-input.js.map +1 -0
  50. package/dist/pty-chunked-input.test.d.ts +2 -0
  51. package/dist/pty-chunked-input.test.d.ts.map +1 -0
  52. package/dist/pty-chunked-input.test.js +115 -0
  53. package/dist/pty-chunked-input.test.js.map +1 -0
  54. package/dist/worker.d.ts +3 -0
  55. package/dist/worker.d.ts.map +1 -0
  56. package/dist/worker.js +5 -0
  57. package/dist/worker.js.map +1 -0
  58. 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