@agent-relay/sandbox 0.0.0 → 0.1.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.
@@ -0,0 +1,811 @@
1
+ export class SnapshotNotFoundError extends Error {
2
+ snapshot;
3
+ constructor(snapshot, cause) {
4
+ super(`Snapshot not found in Daytona: '${snapshot}'. Refusing silent fallback to typescript base — fix DEFAULT_SNAPSHOT or rebuild/publish the snapshot before retrying.`, { cause });
5
+ this.name = 'SnapshotNotFoundError';
6
+ this.snapshot = snapshot;
7
+ }
8
+ }
9
+ // Upper bound on how many trailing bytes of a run's captured log file
10
+ // getScriptLogs pulls back into the polling process. A long run can emit MBs
11
+ // of stdout; `tail -c` bounds the read at the source so the poller never
12
+ // buffers the whole file. The failure is almost always at the END of the run,
13
+ // so the trailing bytes are the useful ones.
14
+ const SCRIPT_LOG_READ_MAX_BYTES = 262_144; // 256 KiB
15
+ const DEFAULT_DAYTONA_LOOKUP_TIMEOUT_MS = 10_000;
16
+ export class DaytonaRuntime {
17
+ id = 'daytona';
18
+ capabilities = {
19
+ pty: false,
20
+ snapshots: true,
21
+ isolation: 'strong',
22
+ persistentHandle: true,
23
+ streamingLogs: true,
24
+ };
25
+ sandboxes = new Map();
26
+ daytona;
27
+ snapshot;
28
+ defaultHomeDir;
29
+ constructor(options) {
30
+ this.daytona = options.daytona;
31
+ this.snapshot = options.snapshot;
32
+ this.defaultHomeDir = options.defaultHomeDir;
33
+ }
34
+ async launch(options = {}) {
35
+ const sandbox = await this.createSandbox(options);
36
+ const homeDir = await this.resolveHomeDir(sandbox);
37
+ return this.registerSandbox(sandbox, {
38
+ owned: true,
39
+ homeDir,
40
+ workdir: options.workdir,
41
+ });
42
+ }
43
+ async launchDetached(options = {}) {
44
+ const sandbox = await this.createSandboxDetached(options);
45
+ if (isRuntimeHandle(sandbox)) {
46
+ return {
47
+ ...sandbox,
48
+ ...(options.workdir ? { workdir: options.workdir } : {}),
49
+ };
50
+ }
51
+ return this.registerSandbox(sandbox, {
52
+ owned: true,
53
+ workdir: options.workdir,
54
+ });
55
+ }
56
+ async getById(id, options = {}) {
57
+ let sandbox;
58
+ try {
59
+ sandbox = await this.daytona.get(id);
60
+ }
61
+ catch (error) {
62
+ if (isDaytonaNotFoundError(error)) {
63
+ return null;
64
+ }
65
+ throw error;
66
+ }
67
+ const states = options.states === undefined ? null : options.states;
68
+ if (!this.matchesState(sandbox, states)) {
69
+ return null;
70
+ }
71
+ return this.registerSandbox(sandbox, {
72
+ owned: options.owned ?? false,
73
+ homeDir: options.homeDir,
74
+ workdir: options.workdir,
75
+ });
76
+ }
77
+ async findByLabels(labels, options = {}) {
78
+ const limit = options.limit ?? options.pageSize ?? 10;
79
+ const states = options.states === undefined ? ['STARTED'] : options.states;
80
+ const excludedIds = new Set(options.excludeIds ?? []);
81
+ const deadline = lookupDeadline(options.timeoutMs);
82
+ const iterator = this.listSandboxes(labels, { limit, states });
83
+ try {
84
+ for (;;) {
85
+ const next = await awaitLookupOperation(iterator.next(), deadline, 'listing matching sandboxes');
86
+ if (next.done) {
87
+ return null;
88
+ }
89
+ const listedSandbox = next.value;
90
+ if (!this.matchesState(listedSandbox, states) || excludedIds.has(listedSandbox.id)) {
91
+ continue;
92
+ }
93
+ // Daytona SDK 0.180 shares mutable client configuration across list()
94
+ // results. Rehydrate only the candidate we are about to return so its
95
+ // filesystem and process clients are pinned to the same sandbox ID.
96
+ let sandbox;
97
+ try {
98
+ sandbox = await awaitLookupOperation(this.daytona.get(listedSandbox.id), deadline, `rehydrating sandbox ${listedSandbox.id}`);
99
+ }
100
+ catch (error) {
101
+ if (isDaytonaNotFoundError(error)) {
102
+ continue;
103
+ }
104
+ throw error;
105
+ }
106
+ if (!this.matchesState(sandbox, states)) {
107
+ continue;
108
+ }
109
+ const homeDir = options.homeDir ?? await awaitLookupOperation(this.resolveHomeDir(sandbox), deadline, `resolving sandbox ${sandbox.id} home directory`);
110
+ return this.registerSandbox(sandbox, {
111
+ owned: options.owned ?? false,
112
+ homeDir,
113
+ workdir: options.workdir,
114
+ });
115
+ }
116
+ }
117
+ finally {
118
+ closeAsyncIteratorBestEffort(iterator);
119
+ }
120
+ }
121
+ async countByLabels(labels, options = {}) {
122
+ const limit = options.limit ?? options.pageSize ?? 10;
123
+ const states = options.states === undefined ? ['STARTED'] : options.states;
124
+ const maxCount = options.maxCount === undefined
125
+ ? Number.POSITIVE_INFINITY
126
+ : Math.max(0, Math.floor(options.maxCount));
127
+ if (maxCount === 0) {
128
+ return 0;
129
+ }
130
+ const deadline = lookupDeadline(options.timeoutMs);
131
+ const iterator = this.listSandboxes(labels, { limit, states });
132
+ let count = 0;
133
+ try {
134
+ for (;;) {
135
+ const next = await awaitLookupOperation(iterator.next(), deadline, 'counting matching sandboxes');
136
+ if (next.done) {
137
+ return count;
138
+ }
139
+ if (!this.matchesState(next.value, states)) {
140
+ continue;
141
+ }
142
+ count += 1;
143
+ if (count >= maxCount) {
144
+ return count;
145
+ }
146
+ }
147
+ }
148
+ finally {
149
+ closeAsyncIteratorBestEffort(iterator);
150
+ }
151
+ }
152
+ async findAllByLabels(labels, options = {}) {
153
+ const limit = options.limit ?? options.pageSize ?? 10;
154
+ const states = options.states === undefined ? ['STARTED'] : options.states;
155
+ const handles = [];
156
+ for await (const listedSandbox of this.listSandboxes(labels, { limit, states })) {
157
+ if (!this.matchesState(listedSandbox, states)) {
158
+ continue;
159
+ }
160
+ // Daytona SDK 0.180 reuses one mutable client config for every Sandbox
161
+ // yielded by list() (Daytona.js:375,408). Each Sandbox constructor
162
+ // rewrites its basePath (Sandbox.js:198-199), so serverless filesystem
163
+ // uploads read the last-listed sandbox ID (FileSystem.js:512) while
164
+ // process commands remain bound to the earlier sandbox. get() clones the
165
+ // config per Sandbox (Daytona.js:356); rehydrate before keeping any
166
+ // listed result so upload, verification, and execution share one ID.
167
+ let sandbox;
168
+ try {
169
+ sandbox = await this.daytona.get(listedSandbox.id);
170
+ }
171
+ catch (error) {
172
+ if (isDaytonaNotFoundError(error)) {
173
+ continue;
174
+ }
175
+ throw error;
176
+ }
177
+ if (!this.matchesState(sandbox, states)) {
178
+ continue;
179
+ }
180
+ handles.push(this.registerSandbox(sandbox, {
181
+ owned: options.owned ?? false,
182
+ homeDir: options.homeDir,
183
+ workdir: options.workdir,
184
+ }));
185
+ }
186
+ return handles;
187
+ }
188
+ attachSandbox(sandbox, options = {}) {
189
+ return this.registerSandbox(sandbox, {
190
+ owned: options.owned ?? false,
191
+ homeDir: options.homeDir,
192
+ workdir: options.workdir,
193
+ });
194
+ }
195
+ async exec(handle, command, options = {}) {
196
+ const sandbox = this.requireSandbox(handle);
197
+ const result = await sandbox.process.executeCommand(command, options.cwd, options.env, this.msToSeconds(options.timeoutMs));
198
+ return {
199
+ output: result.result ?? '',
200
+ exitCode: result.exitCode ?? 0,
201
+ };
202
+ }
203
+ async runScript(handle, options) {
204
+ const sandbox = this.requireSandbox(handle);
205
+ const command = this.buildScriptCommand(options);
206
+ const timeoutSeconds = this.msToSeconds(options.timeoutMs);
207
+ const useSession = options.useSession ?? true;
208
+ if (useSession) {
209
+ if (!this.supportsSessionExec(sandbox)) {
210
+ throw new Error('Daytona session execution is not available on this sandbox');
211
+ }
212
+ const sessionId = options.sessionId ?? `run-${handle.id}-${Date.now()}`;
213
+ await sandbox.process.createSession(sessionId);
214
+ const result = await sandbox.process.executeSessionCommand(sessionId, {
215
+ command,
216
+ runAsync: false,
217
+ suppressInputEcho: options.suppressInputEcho,
218
+ }, timeoutSeconds);
219
+ return {
220
+ output: result.output ?? result.stdout ?? result.stderr ?? '',
221
+ ...(result.stdout ? { stdout: result.stdout } : {}),
222
+ ...(result.stderr ? { stderr: result.stderr } : {}),
223
+ exitCode: typeof result.exitCode === 'number' ? result.exitCode : null,
224
+ ...(result.cmdId ? { cmdId: result.cmdId } : {}),
225
+ };
226
+ }
227
+ const result = await sandbox.process.executeCommand(command, undefined, undefined, timeoutSeconds);
228
+ return {
229
+ output: result.result ?? result.artifacts?.stdout ?? '',
230
+ ...(result.artifacts?.stdout ? { stdout: result.artifacts.stdout } : {}),
231
+ exitCode: typeof result.exitCode === 'number' ? result.exitCode : null,
232
+ };
233
+ }
234
+ async startScript(handle, options) {
235
+ const sandbox = this.requireSandbox(handle);
236
+ if (!this.supportsSessionExec(sandbox)) {
237
+ throw new Error('Daytona session execution is not available on this sandbox');
238
+ }
239
+ const sessionId = options.sessionId ?? `run-${handle.id}-${Date.now()}`;
240
+ const statusPath = this.scriptStatusPath(sessionId);
241
+ const pendingStatusPath = `${statusPath}.tmp`;
242
+ const cleanup = await sandbox.process.executeCommand(`rm -f ${shellSingleQuote(statusPath)} ${shellSingleQuote(pendingStatusPath)}`);
243
+ if (cleanup.exitCode !== 0) {
244
+ throw new Error(`Failed to clear stale Daytona status for session ${sessionId}`);
245
+ }
246
+ await sandbox.process.createSession(sessionId);
247
+ // Capture the run's combined stdout+stderr to a per-session log file.
248
+ //
249
+ // Daytona's REST `getSessionCommandLogs` snapshot returns EMPTY for
250
+ // `runAsync: true` commands, and the command RECORD can remain without an
251
+ // exitCode after completion. The log BODY is otherwise only retrievable via the
252
+ // follow=true WebSocket stream, which the SDK implements with
253
+ // `isomorphic-ws` → node `ws`, so it does NOT run on edge runtimes without
254
+ // WebSocket client support — which is where these runs are typically
255
+ // polled from. Without capture, every poll reads empty output and a
256
+ // failing run surfaces only as the bare "runner.mjs failed" fallback
257
+ // string.
258
+ //
259
+ // A subshell redirects only this run's script, then its parent atomically
260
+ // persists the exit code. Fresh one-shot readers recover both files after
261
+ // Daytona closes the original async session.
262
+ const logPath = this.scriptLogPath(sessionId);
263
+ const command = [
264
+ `(`,
265
+ this.buildScriptCommand(options),
266
+ `) > ${shellSingleQuote(logPath)} 2>&1`,
267
+ 'daytona_run_status=$?',
268
+ `printf '%s\\n' "$daytona_run_status" > ${shellSingleQuote(pendingStatusPath)}`,
269
+ `mv ${shellSingleQuote(pendingStatusPath)} ${shellSingleQuote(statusPath)}`,
270
+ 'exit "$daytona_run_status"',
271
+ ].join('\n');
272
+ const result = await sandbox.process.executeSessionCommand(sessionId, {
273
+ command,
274
+ runAsync: true,
275
+ suppressInputEcho: options.suppressInputEcho,
276
+ }, this.msToSeconds(options.timeoutMs));
277
+ if (!result.cmdId) {
278
+ throw new Error('Daytona async session command did not return a command id');
279
+ }
280
+ return { sessionId, commandId: result.cmdId };
281
+ }
282
+ async getScriptStatus(handle, sessionId, commandId) {
283
+ const sandbox = this.requireSandbox(handle);
284
+ if (!this.supportsSessionExec(sandbox)) {
285
+ throw new Error('Daytona session execution is not available on this sandbox');
286
+ }
287
+ const command = await sandbox.process.getSessionCommand(sessionId, commandId);
288
+ if (typeof command.exitCode === 'number') {
289
+ return { exitCode: command.exitCode };
290
+ }
291
+ // Daytona's REST command projection can remain at exitCode:null after the
292
+ // async process has already finished. startScript writes an atomic status
293
+ // sidecar from inside the same shell; consult it before reporting running.
294
+ try {
295
+ const statusPath = this.scriptStatusPath(sessionId);
296
+ // The original async session is closed when its shell exits; Daytona
297
+ // rejects later commands on that session with a broken pipe. Read the
298
+ // durable sidecar through a fresh one-shot process instead.
299
+ const result = await sandbox.process.executeCommand(`if [ -f ${shellSingleQuote(statusPath)} ]; then cat ${shellSingleQuote(statusPath)}; fi`);
300
+ const output = result.result ?? result.artifacts?.stdout ?? '';
301
+ const exitCode = parseShellExitCode(output);
302
+ if (exitCode !== null) {
303
+ return { exitCode };
304
+ }
305
+ }
306
+ catch {
307
+ // Best-effort fallback. A missing file means the command is still
308
+ // running; a transient status read will be retried by the caller.
309
+ }
310
+ return {
311
+ exitCode: null,
312
+ };
313
+ }
314
+ async getScriptLogs(handle, sessionId, commandId) {
315
+ const sandbox = this.requireSandbox(handle);
316
+ if (!this.supportsSessionExec(sandbox)) {
317
+ throw new Error('Daytona session execution is not available on this sandbox');
318
+ }
319
+ const logs = await sandbox.process.getSessionCommandLogs(sessionId, commandId);
320
+ let output = logs.output ?? logs.stdout ?? logs.stderr ?? '';
321
+ // Fallback for runAsync commands whose snapshot logs come back empty (see
322
+ // startScript): read the per-session redirect file we captured. Bounded at
323
+ // the source with `tail -c` so a multi-MB run can't pull the whole file
324
+ // into the poller. The read uses a fresh one-shot process, which returns
325
+ // output inline over REST and works without WebSocket support. Best-effort:
326
+ // a recycled sandbox or missing file yields empty, never a throw.
327
+ if (!output) {
328
+ try {
329
+ const logPath = this.scriptLogPath(sessionId);
330
+ // Completed async sessions reject additional session commands. A
331
+ // one-shot process can still read the sandbox-scoped capture file.
332
+ const fileLogs = await sandbox.process.executeCommand(`tail -c ${SCRIPT_LOG_READ_MAX_BYTES} ${shellSingleQuote(logPath)} 2>/dev/null || true`);
333
+ output = fileLogs.result ?? fileLogs.artifacts?.stdout ?? '';
334
+ }
335
+ catch {
336
+ // best-effort; keep the empty snapshot result
337
+ }
338
+ }
339
+ return {
340
+ output,
341
+ ...(logs.stdout ? { stdout: logs.stdout } : {}),
342
+ ...(logs.stderr ? { stderr: logs.stderr } : {}),
343
+ exitCode: null,
344
+ cmdId: commandId,
345
+ };
346
+ }
347
+ startExec(handle, command, options = {}) {
348
+ return this.startScript(handle, {
349
+ command,
350
+ sessionId: options.sessionId,
351
+ timeoutMs: options.timeoutMs,
352
+ env: options.env,
353
+ useSession: true,
354
+ suppressInputEcho: true,
355
+ });
356
+ }
357
+ getExecStatus(handle, sessionId, commandId) {
358
+ return this.getScriptStatus(handle, sessionId, commandId);
359
+ }
360
+ async getExecLogs(handle, sessionId, commandId) {
361
+ const logs = await this.getScriptLogs(handle, sessionId, commandId);
362
+ return {
363
+ output: logs.output,
364
+ exitCode: logs.exitCode ?? 0,
365
+ };
366
+ }
367
+ async uploadFile(handle, source, destination) {
368
+ const sandbox = this.requireSandbox(handle);
369
+ if (typeof source === 'string') {
370
+ await sandbox.fs.uploadFile(source, destination);
371
+ return;
372
+ }
373
+ await sandbox.fs.uploadFile(source, destination);
374
+ }
375
+ async uploadBundle(handle, options) {
376
+ await this.ensureUploadParentDirectories(handle, this.uploadParentDirectories(options));
377
+ for (const file of options.files) {
378
+ await this.uploadFile(handle, file.source, file.destination);
379
+ }
380
+ if (options.manifest !== undefined) {
381
+ await this.uploadFile(handle, Buffer.from(JSON.stringify(options.manifest, null, 2), 'utf8'), options.manifestPath ?? '/workspace/manifest.json');
382
+ }
383
+ await this.verifyUploadedBundleFiles(handle, this.uploadDestinations(options));
384
+ }
385
+ async downloadFile(handle, source, destination) {
386
+ const sandbox = this.requireSandbox(handle);
387
+ if (destination) {
388
+ await sandbox.fs.downloadFile(source, destination);
389
+ return;
390
+ }
391
+ return sandbox.fs.downloadFile(source);
392
+ }
393
+ async getHomeDir(handle) {
394
+ if (handle.homeDir) {
395
+ return handle.homeDir;
396
+ }
397
+ const sandbox = this.requireSandbox(handle);
398
+ const homeDir = await this.resolveHomeDir(sandbox);
399
+ handle.homeDir = homeDir;
400
+ return homeDir;
401
+ }
402
+ async destroy(handle) {
403
+ const entry = this.sandboxes.get(handle.id);
404
+ if (!entry) {
405
+ return;
406
+ }
407
+ if (!entry.owned) {
408
+ // For attached (non-owned) sandboxes we never call the remote
409
+ // delete; just drop the local registration so the caller-managed
410
+ // resource isn't tracked here any more.
411
+ this.sandboxes.delete(handle.id);
412
+ return;
413
+ }
414
+ const client = this.daytona;
415
+ const remove = client.remove ?? client.delete;
416
+ // Order matters: do the remote delete *first*, and only drop the
417
+ // local map entry after it succeeds. If we dropped the entry first
418
+ // and the remote delete then failed, the handle id would be lost
419
+ // and the caller could not retry cleanup safely.
420
+ await remove.call(client, entry.sandbox);
421
+ this.sandboxes.delete(handle.id);
422
+ }
423
+ async stop(handle) {
424
+ const entry = this.sandboxes.get(handle.id);
425
+ if (!entry) {
426
+ return;
427
+ }
428
+ if (!entry.owned) {
429
+ return;
430
+ }
431
+ const client = this.daytona;
432
+ if (client.stop) {
433
+ await client.stop(entry.sandbox);
434
+ return;
435
+ }
436
+ await entry.sandbox.stop?.();
437
+ }
438
+ async start(handle) {
439
+ const entry = this.sandboxes.get(handle.id);
440
+ if (!entry) {
441
+ return handle;
442
+ }
443
+ if (!entry.owned) {
444
+ return handle;
445
+ }
446
+ const client = this.daytona;
447
+ if (client.start) {
448
+ await client.start(entry.sandbox);
449
+ }
450
+ else {
451
+ await entry.sandbox.start?.();
452
+ }
453
+ handle.state = 'STARTED';
454
+ return handle;
455
+ }
456
+ async createSandbox(options) {
457
+ const params = this.buildCreateParams(options);
458
+ const createOptions = this.buildCreateOptions(options);
459
+ if (this.snapshot) {
460
+ try {
461
+ return await this.createWithOptions({ snapshot: this.snapshot, ...params }, createOptions);
462
+ }
463
+ catch (err) {
464
+ // Only fall back to a fresh sandbox when the snapshot itself is
465
+ // missing. Auth/network/quota errors should bubble — otherwise
466
+ // we silently mask real failures (a 401 ends up creating an
467
+ // unsnapshotted sandbox under whichever credentials worked).
468
+ if (!isSnapshotNotFoundError(err)) {
469
+ throw err;
470
+ }
471
+ }
472
+ }
473
+ return this.createWithOptions({ language: 'typescript', ...params }, createOptions);
474
+ }
475
+ async createSandboxDetached(options) {
476
+ const params = this.buildCreateParams(options);
477
+ const createOptions = this.buildCreateOptions(options);
478
+ if (this.snapshot) {
479
+ try {
480
+ return await this.createDetachedWithOptions({ snapshot: this.snapshot, ...params }, createOptions);
481
+ }
482
+ catch (err) {
483
+ if (!isSnapshotNotFoundError(err)) {
484
+ throw err;
485
+ }
486
+ throw new SnapshotNotFoundError(this.snapshot, err);
487
+ }
488
+ }
489
+ return this.createDetachedWithOptions({ language: 'typescript', ...params }, createOptions);
490
+ }
491
+ buildCreateParams(options) {
492
+ const envVars = options.env && Object.keys(options.env).length > 0 ? options.env : undefined;
493
+ const name = options.name?.trim()
494
+ ? options.name.trim()
495
+ : options.label?.trim()
496
+ ? options.label.trim()
497
+ : undefined;
498
+ const labels = options.labels && Object.keys(options.labels).length > 0 ? options.labels : undefined;
499
+ return {
500
+ ...(envVars ? { envVars } : {}),
501
+ ...(name ? { name } : {}),
502
+ ...(labels ? { labels } : {}),
503
+ };
504
+ }
505
+ buildCreateOptions(options) {
506
+ if (!options.createTimeoutSeconds || options.createTimeoutSeconds <= 0) {
507
+ return undefined;
508
+ }
509
+ return { timeout: Math.ceil(options.createTimeoutSeconds) };
510
+ }
511
+ createWithOptions(params, createOptions) {
512
+ if (createOptions) {
513
+ return this.daytona.create(params, createOptions);
514
+ }
515
+ return this.daytona.create(params);
516
+ }
517
+ async createDetachedWithOptions(params, createOptions) {
518
+ const client = this.daytona;
519
+ const labels = params.labels && typeof params.labels === 'object'
520
+ ? { ...params.labels }
521
+ : {};
522
+ const language = typeof params.language === 'string' && params.language.trim()
523
+ ? params.language.trim()
524
+ : 'python';
525
+ labels['code-toolbox-language'] = language;
526
+ const response = await client.sandboxApi.createSandbox({
527
+ name: params.name,
528
+ snapshot: params.snapshot,
529
+ env: params.envVars ?? {},
530
+ labels,
531
+ target: client.target,
532
+ }, undefined, createOptions ? { timeout: Math.min(createOptions.timeout, 15) * 1000 } : undefined);
533
+ const handle = {
534
+ id: response.data.id,
535
+ ...((response.data.state ?? response.data.status)
536
+ ? { state: response.data.state ?? response.data.status }
537
+ : {}),
538
+ };
539
+ if (!this.matchesState(handle, ['STARTED'])) {
540
+ return handle;
541
+ }
542
+ try {
543
+ return await client.get(response.data.id);
544
+ }
545
+ catch {
546
+ return { ...handle, state: 'STARTING' };
547
+ }
548
+ }
549
+ listSandboxes(labels, options) {
550
+ const query = {
551
+ labels,
552
+ limit: options.limit,
553
+ };
554
+ if (options.states !== null) {
555
+ query.states = options.states.map(normalizeDaytonaState);
556
+ }
557
+ return this.daytona.list(query);
558
+ }
559
+ registerSandbox(sandbox, options) {
560
+ const handle = {
561
+ id: sandbox.id,
562
+ ...(this.readSandboxState(sandbox) ? { state: this.readSandboxState(sandbox) } : {}),
563
+ ...(sandbox.createdAt ? { createdAt: sandbox.createdAt } : {}),
564
+ ...(sandbox.updatedAt ? { updatedAt: sandbox.updatedAt } : {}),
565
+ ...(sandbox.lastActivityAt ? { lastActivityAt: sandbox.lastActivityAt } : {}),
566
+ ...(options.homeDir ? { homeDir: options.homeDir } : {}),
567
+ ...(options.workdir ? { workdir: options.workdir } : {}),
568
+ };
569
+ this.sandboxes.set(handle.id, {
570
+ sandbox,
571
+ owned: options.owned,
572
+ });
573
+ return handle;
574
+ }
575
+ requireSandbox(handle) {
576
+ const entry = this.sandboxes.get(handle.id);
577
+ if (!entry) {
578
+ throw new Error(`Runtime handle "${handle.id}" is no longer active`);
579
+ }
580
+ return entry.sandbox;
581
+ }
582
+ supportsSessionExec(sandbox) {
583
+ const process = sandbox.process;
584
+ if (!process || typeof process !== 'object') {
585
+ return false;
586
+ }
587
+ const candidate = process;
588
+ return (typeof candidate.createSession === 'function' &&
589
+ typeof candidate.executeSessionCommand === 'function');
590
+ }
591
+ buildScriptCommand(options) {
592
+ const statements = [];
593
+ if (options.cwd) {
594
+ statements.push(`cd ${shellSingleQuote(options.cwd)}`);
595
+ }
596
+ for (const [key, value] of Object.entries(options.env ?? {})) {
597
+ statements.push(`export ${key}=${shellSingleQuote(value)}`);
598
+ }
599
+ statements.push(options.command);
600
+ return statements.join('\n');
601
+ }
602
+ // Deterministic per-session log path written by startScript's `exec`
603
+ // redirect and read back by getScriptLogs. Keyed by sessionId (known before
604
+ // the command id exists) and filesystem-sanitised. Callers that run one
605
+ // command per session get an unambiguous path back.
606
+ scriptLogPath(sessionId) {
607
+ return `/tmp/.daytona-run-${sessionSafeId(sessionId)}.log`;
608
+ }
609
+ scriptStatusPath(sessionId) {
610
+ return `/tmp/.daytona-run-${sessionSafeId(sessionId)}.exit`;
611
+ }
612
+ matchesState(sandbox, states) {
613
+ if (states === null) {
614
+ return true;
615
+ }
616
+ const expected = new Set(states.map((state) => state.toUpperCase()));
617
+ const actual = this.readSandboxState(sandbox);
618
+ return actual ? expected.has(actual.toUpperCase()) : false;
619
+ }
620
+ readSandboxState(sandbox) {
621
+ const candidate = sandbox;
622
+ const value = candidate.state
623
+ ?? candidate.status
624
+ ?? candidate.sandboxState
625
+ ?? candidate.info?.state
626
+ ?? candidate.info?.status;
627
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
628
+ }
629
+ uploadParentDirectories(options) {
630
+ const directories = new Set();
631
+ for (const destination of this.uploadDestinations(options)) {
632
+ const directory = parentDirectory(destination);
633
+ if (directory) {
634
+ directories.add(directory);
635
+ }
636
+ }
637
+ return Array.from(directories).sort();
638
+ }
639
+ uploadDestinations(options) {
640
+ const destinations = options.files.map((file) => file.destination);
641
+ if (options.manifest !== undefined) {
642
+ destinations.push(options.manifestPath ?? '/workspace/manifest.json');
643
+ }
644
+ return destinations;
645
+ }
646
+ async ensureUploadParentDirectories(handle, directories) {
647
+ if (directories.length === 0) {
648
+ return;
649
+ }
650
+ const result = await this.runScript(handle, {
651
+ command: `mkdir -p ${directories.map(shellSingleQuote).join(' ')}`,
652
+ sessionId: `mkdir-${sessionSafeId(handle.id)}-${Date.now()}`,
653
+ timeoutMs: 30_000,
654
+ });
655
+ if (result.exitCode == null || result.exitCode !== 0) {
656
+ throw new Error(`Failed to create upload directories: ${result.output || result.stderr || result.stdout || 'mkdir failed'}`);
657
+ }
658
+ }
659
+ async verifyUploadedBundleFiles(handle, destinations) {
660
+ if (destinations.length === 0) {
661
+ return;
662
+ }
663
+ const checks = destinations
664
+ .map((destination) => `test -f ${shellSingleQuote(destination)}`)
665
+ .join(' && ');
666
+ const result = await this.runScript(handle, {
667
+ command: checks,
668
+ sessionId: `verify-upload-${sessionSafeId(handle.id)}-${Date.now()}`,
669
+ timeoutMs: 30_000,
670
+ });
671
+ if (result.exitCode == null || result.exitCode !== 0) {
672
+ throw new Error(`Failed to verify uploaded bundle files: ${result.output || result.stderr || result.stdout || 'remote file check failed'}`);
673
+ }
674
+ }
675
+ async resolveHomeDir(sandbox) {
676
+ try {
677
+ const home = await sandbox.getUserHomeDir();
678
+ if (home) {
679
+ return home;
680
+ }
681
+ }
682
+ catch {
683
+ // fall through to default
684
+ }
685
+ return this.defaultHomeDir;
686
+ }
687
+ msToSeconds(timeoutMs) {
688
+ if (!timeoutMs || timeoutMs <= 0) {
689
+ return undefined;
690
+ }
691
+ return Math.max(1, Math.ceil(timeoutMs / 1000));
692
+ }
693
+ }
694
+ function normalizeDaytonaState(state) {
695
+ return state.toLowerCase();
696
+ }
697
+ function lookupDeadline(timeoutMs) {
698
+ const effectiveTimeoutMs = timeoutMs ?? DEFAULT_DAYTONA_LOOKUP_TIMEOUT_MS;
699
+ const normalizedTimeoutMs = Number.isFinite(effectiveTimeoutMs) && effectiveTimeoutMs > 0
700
+ ? Math.max(1, Math.ceil(effectiveTimeoutMs))
701
+ : DEFAULT_DAYTONA_LOOKUP_TIMEOUT_MS;
702
+ return {
703
+ endsAt: Date.now() + normalizedTimeoutMs,
704
+ timeoutMs: normalizedTimeoutMs,
705
+ };
706
+ }
707
+ async function awaitLookupOperation(operation, deadline, description) {
708
+ const remainingMs = deadline.endsAt - Date.now();
709
+ if (remainingMs <= 0) {
710
+ throw new Error(`Daytona sandbox lookup exceeded ${deadline.timeoutMs}ms while ${description}`);
711
+ }
712
+ let timer;
713
+ try {
714
+ return await Promise.race([
715
+ operation,
716
+ new Promise((_resolve, reject) => {
717
+ timer = setTimeout(() => {
718
+ reject(new Error(`Daytona sandbox lookup exceeded ${deadline.timeoutMs}ms while ${description}`));
719
+ }, remainingMs);
720
+ }),
721
+ ]);
722
+ }
723
+ finally {
724
+ if (timer !== undefined) {
725
+ clearTimeout(timer);
726
+ }
727
+ }
728
+ }
729
+ function closeAsyncIteratorBestEffort(iterator) {
730
+ if (!iterator.return) {
731
+ return;
732
+ }
733
+ try {
734
+ void iterator.return().catch(() => undefined);
735
+ }
736
+ catch {
737
+ // The lookup result or timeout is authoritative; iterator cleanup is best effort.
738
+ }
739
+ }
740
+ function isRuntimeHandle(value) {
741
+ return !('getUserHomeDir' in value);
742
+ }
743
+ function shellSingleQuote(value) {
744
+ return `'${value.replaceAll("'", "'\\''")}'`;
745
+ }
746
+ function sessionSafeId(value) {
747
+ return value.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'sandbox';
748
+ }
749
+ function parseShellExitCode(value) {
750
+ const normalized = value.trim();
751
+ if (!/^\d{1,3}$/u.test(normalized)) {
752
+ return null;
753
+ }
754
+ const exitCode = Number(normalized);
755
+ return Number.isInteger(exitCode) && exitCode >= 0 && exitCode <= 255
756
+ ? exitCode
757
+ : null;
758
+ }
759
+ function parentDirectory(destination) {
760
+ const normalized = destination.trim().replace(/\/+$/g, '');
761
+ if (!normalized || normalized === '/' || !normalized.includes('/')) {
762
+ return null;
763
+ }
764
+ const separatorIndex = normalized.lastIndexOf('/');
765
+ if (separatorIndex <= 0) {
766
+ return null;
767
+ }
768
+ const directory = normalized.slice(0, separatorIndex);
769
+ return directory && directory !== '.' ? directory : null;
770
+ }
771
+ /**
772
+ * Heuristic: identify Daytona errors that indicate the snapshot we asked
773
+ * for doesn't exist (so falling back to a fresh sandbox is safe). We look
774
+ * at the HTTP status when the SDK surfaces one, plus a few well-known
775
+ * error-message shapes Daytona emits. Anything else propagates so the
776
+ * caller sees the original error (auth/network/quota/etc.).
777
+ */
778
+ function isSnapshotNotFoundError(err) {
779
+ if (!err || typeof err !== 'object')
780
+ return false;
781
+ const candidate = err;
782
+ const status = typeof candidate.status === 'number'
783
+ ? candidate.status
784
+ : typeof candidate.statusCode === 'number'
785
+ ? candidate.statusCode
786
+ : undefined;
787
+ if (status === 404)
788
+ return true;
789
+ const message = typeof candidate.message === 'string' ? candidate.message.toLowerCase() : '';
790
+ if (!message)
791
+ return false;
792
+ return (message.includes('snapshot') &&
793
+ (message.includes('not found') || message.includes('does not exist') || message.includes('no such')));
794
+ }
795
+ function isDaytonaNotFoundError(err) {
796
+ if (!err || typeof err !== 'object')
797
+ return false;
798
+ const candidate = err;
799
+ const status = typeof candidate.status === 'number'
800
+ ? candidate.status
801
+ : typeof candidate.statusCode === 'number'
802
+ ? candidate.statusCode
803
+ : undefined;
804
+ if (status === 404)
805
+ return true;
806
+ if (candidate.name === 'DaytonaNotFoundError')
807
+ return true;
808
+ const message = typeof candidate.message === 'string' ? candidate.message.toLowerCase() : '';
809
+ return message.includes('sandbox') && message.includes('not found');
810
+ }
811
+ //# sourceMappingURL=runtime.js.map