@tickernelz/wardmote-plugin-daytona 0.0.0 → 2026.1108.1

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