@agent-relay/sandbox 0.1.2 → 0.1.3

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 (121) hide show
  1. package/README.md +226 -5
  2. package/dist/agent37/client.d.ts +115 -0
  3. package/dist/agent37/client.d.ts.map +1 -0
  4. package/dist/agent37/client.js +270 -0
  5. package/dist/agent37/client.js.map +1 -0
  6. package/dist/agent37/runtime.d.ts +415 -0
  7. package/dist/agent37/runtime.d.ts.map +1 -0
  8. package/dist/agent37/runtime.js +855 -0
  9. package/dist/agent37/runtime.js.map +1 -0
  10. package/dist/agentcore/bench.d.ts +93 -0
  11. package/dist/agentcore/bench.d.ts.map +1 -0
  12. package/dist/agentcore/bench.js +125 -0
  13. package/dist/agentcore/bench.js.map +1 -0
  14. package/dist/agentcore/capabilities.d.ts +169 -0
  15. package/dist/agentcore/capabilities.d.ts.map +1 -0
  16. package/dist/agentcore/capabilities.js +153 -0
  17. package/dist/agentcore/capabilities.js.map +1 -0
  18. package/dist/agentcore/config.d.ts +156 -0
  19. package/dist/agentcore/config.d.ts.map +1 -0
  20. package/dist/agentcore/config.js +12 -0
  21. package/dist/agentcore/config.js.map +1 -0
  22. package/dist/agentcore/internal/sdk.d.ts +181 -0
  23. package/dist/agentcore/internal/sdk.d.ts.map +1 -0
  24. package/dist/agentcore/internal/sdk.js +223 -0
  25. package/dist/agentcore/internal/sdk.js.map +1 -0
  26. package/dist/agentcore/runtime.d.ts +177 -0
  27. package/dist/agentcore/runtime.d.ts.map +1 -0
  28. package/dist/agentcore/runtime.js +706 -0
  29. package/dist/agentcore/runtime.js.map +1 -0
  30. package/dist/daytona/runtime.d.ts +23 -0
  31. package/dist/daytona/runtime.d.ts.map +1 -1
  32. package/dist/daytona/runtime.js +167 -5
  33. package/dist/daytona/runtime.js.map +1 -1
  34. package/dist/daytona/wire-supplement.d.ts +25 -0
  35. package/dist/daytona/wire-supplement.d.ts.map +1 -0
  36. package/dist/daytona/wire-supplement.js +16 -0
  37. package/dist/daytona/wire-supplement.js.map +1 -0
  38. package/dist/e2b/runtime.d.ts +107 -72
  39. package/dist/e2b/runtime.d.ts.map +1 -1
  40. package/dist/e2b/runtime.js +675 -190
  41. package/dist/e2b/runtime.js.map +1 -1
  42. package/dist/freestyle/capabilities.d.ts +28 -0
  43. package/dist/freestyle/capabilities.d.ts.map +1 -0
  44. package/dist/freestyle/capabilities.js +42 -0
  45. package/dist/freestyle/capabilities.js.map +1 -0
  46. package/dist/freestyle/config.d.ts +36 -0
  47. package/dist/freestyle/config.d.ts.map +1 -0
  48. package/dist/freestyle/config.js +2 -0
  49. package/dist/freestyle/config.js.map +1 -0
  50. package/dist/freestyle/internal/sdk.d.ts +69 -0
  51. package/dist/freestyle/internal/sdk.d.ts.map +1 -0
  52. package/dist/freestyle/internal/sdk.js +19 -0
  53. package/dist/freestyle/internal/sdk.js.map +1 -0
  54. package/dist/freestyle/runtime.d.ts +163 -0
  55. package/dist/freestyle/runtime.d.ts.map +1 -0
  56. package/dist/freestyle/runtime.js +798 -0
  57. package/dist/freestyle/runtime.js.map +1 -0
  58. package/dist/index.d.ts +35 -4
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +16 -2
  61. package/dist/index.js.map +1 -1
  62. package/dist/microsandbox/runtime.d.ts +793 -0
  63. package/dist/microsandbox/runtime.d.ts.map +1 -0
  64. package/dist/microsandbox/runtime.js +2303 -0
  65. package/dist/microsandbox/runtime.js.map +1 -0
  66. package/dist/modal/bench.d.ts +199 -0
  67. package/dist/modal/bench.d.ts.map +1 -0
  68. package/dist/modal/bench.js +450 -0
  69. package/dist/modal/bench.js.map +1 -0
  70. package/dist/modal/capabilities.d.ts +180 -0
  71. package/dist/modal/capabilities.d.ts.map +1 -0
  72. package/dist/modal/capabilities.js +159 -0
  73. package/dist/modal/capabilities.js.map +1 -0
  74. package/dist/modal/config.d.ts +192 -0
  75. package/dist/modal/config.d.ts.map +1 -0
  76. package/dist/modal/config.js +169 -0
  77. package/dist/modal/config.js.map +1 -0
  78. package/dist/modal/internal/sdk.d.ts +141 -0
  79. package/dist/modal/internal/sdk.d.ts.map +1 -0
  80. package/dist/modal/internal/sdk.js +51 -0
  81. package/dist/modal/internal/sdk.js.map +1 -0
  82. package/dist/modal/runtime.d.ts +271 -0
  83. package/dist/modal/runtime.d.ts.map +1 -0
  84. package/dist/modal/runtime.js +871 -0
  85. package/dist/modal/runtime.js.map +1 -0
  86. package/dist/mount-script.d.ts +16 -1
  87. package/dist/mount-script.d.ts.map +1 -1
  88. package/dist/mount-script.js +76 -15
  89. package/dist/mount-script.js.map +1 -1
  90. package/dist/orchestrator.d.ts +15 -0
  91. package/dist/orchestrator.d.ts.map +1 -1
  92. package/dist/orchestrator.js +13 -1
  93. package/dist/orchestrator.js.map +1 -1
  94. package/dist/port.d.ts +144 -6
  95. package/dist/port.d.ts.map +1 -1
  96. package/dist/port.js +27 -5
  97. package/dist/port.js.map +1 -1
  98. package/dist/types.d.ts +50 -1
  99. package/dist/types.d.ts.map +1 -1
  100. package/dist/vercel/bench.d.ts +169 -0
  101. package/dist/vercel/bench.d.ts.map +1 -0
  102. package/dist/vercel/bench.js +337 -0
  103. package/dist/vercel/bench.js.map +1 -0
  104. package/dist/vercel/capabilities.d.ts +106 -0
  105. package/dist/vercel/capabilities.d.ts.map +1 -0
  106. package/dist/vercel/capabilities.js +128 -0
  107. package/dist/vercel/capabilities.js.map +1 -0
  108. package/dist/vercel/config.d.ts +119 -0
  109. package/dist/vercel/config.d.ts.map +1 -0
  110. package/dist/vercel/config.js +8 -0
  111. package/dist/vercel/config.js.map +1 -0
  112. package/dist/vercel/internal/sdk.d.ts +173 -0
  113. package/dist/vercel/internal/sdk.d.ts.map +1 -0
  114. package/dist/vercel/internal/sdk.js +117 -0
  115. package/dist/vercel/internal/sdk.js.map +1 -0
  116. package/dist/vercel/runtime.d.ts +305 -0
  117. package/dist/vercel/runtime.d.ts.map +1 -0
  118. package/dist/vercel/runtime.js +1169 -0
  119. package/dist/vercel/runtime.js.map +1 -0
  120. package/docs/freestyle.md +110 -0
  121. package/package.json +35 -4
@@ -0,0 +1,871 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { randomUUID } from "node:crypto";
3
+ import { modalCapabilityModes, modalSandboxCapabilities, } from "./capabilities.js";
4
+ import { resolveModalRuntimeOptions, } from "./config.js";
5
+ import { createOfficialModalClient, } from "./internal/sdk.js";
6
+ /**
7
+ * Modal sandbox runtime.
8
+ *
9
+ * ## How Modal's model differs, and what this adapter does about it
10
+ *
11
+ * **Sandboxes are children of an App, built from an Image.** `create` takes
12
+ * both, so the runtime resolves one `App` and one `Image` up front and reuses
13
+ * them. Neither has a default; both come from config.
14
+ *
15
+ * **Sandboxes have a maximum lifetime and the SDK default is five minutes.**
16
+ * The provider terminates the sandbox when it elapses, running work included.
17
+ * `maxLifetimeMs` is therefore required config and is always sent explicitly.
18
+ * It is unrelated to any request deadline — see the note on
19
+ * `createTimeoutSeconds` in {@link ModalRuntime.launch}.
20
+ *
21
+ * **Sandboxes cannot be stopped and restarted.** `terminate()` is the only
22
+ * lifecycle transition and it is terminal. `start`/`stop` are absent from this
23
+ * class rather than present as no-ops, and `lifecycle: false` is declared so
24
+ * the capability resolver — which cannot see an absent method — agrees.
25
+ *
26
+ * **Ownership rides on Modal's native, server-side tags.** Every sandbox is
27
+ * created carrying an ownership tag, every lookup filters on it server-side,
28
+ * and every destructive or reattaching operation re-checks it. Collision
29
+ * safety is structural here, not a naming convention.
30
+ *
31
+ * **Async exec is deliberately not implemented.** Modal's `exec` hands back a
32
+ * live `ContainerProcess`, but nothing public re-resolves one from an id after
33
+ * the fact. Implementing `startScript` without a real `getScriptStatus` would
34
+ * let a caller submit a command it could never poll or reap — precisely the
35
+ * failure the port's all-or-nothing `asyncExec` rule exists to prevent. The
36
+ * trio is omitted, so the resolver derives `asyncExec: false`.
37
+ *
38
+ * **V1 `create` is pinned on purpose.** Modal also offers
39
+ * `experimentalCreate` (the V2 backend). V2 sandboxes do not support tags and
40
+ * are not returned by `sandboxes.list()`, which would silently destroy both the
41
+ * ownership model and the cleanup story above. Do not "upgrade" this call.
42
+ */
43
+ export class ModalRuntime {
44
+ id = "modal";
45
+ declaredCapabilities = modalSandboxCapabilities;
46
+ declaredCapabilityModes = modalCapabilityModes;
47
+ options;
48
+ clientFactory;
49
+ clientPromise = null;
50
+ contextPromise = null;
51
+ /**
52
+ * Reconciliation tasks that outlive their caller.
53
+ *
54
+ * The only source today is `launch()` racing a `sandboxes.create()` past its
55
+ * client-side deadline: when the deadline fires the create can still complete
56
+ * and hand back a sandbox that would otherwise bill silently. `close()` awaits
57
+ * these before releasing the gRPC channel so that a reconciliation cannot be
58
+ * orphaned by teardown.
59
+ */
60
+ reconciliations = new Set();
61
+ constructor(options) {
62
+ this.options = resolveModalRuntimeOptions(options);
63
+ this.clientFactory = options.clientFactory ?? createOfficialModalClient;
64
+ reconcileModalCapabilities(this);
65
+ }
66
+ // --- lookup -------------------------------------------------------------
67
+ async findByLabels(labels, options = {}) {
68
+ // Preserve a caller-supplied `limit: 0`: a zero-result lookup is a valid
69
+ // question with a defined answer, not a value to round up to one.
70
+ if (normalizePositiveInt(options.limit) === 0) {
71
+ return null;
72
+ }
73
+ const matches = await this.findAllByLabels(labels, { ...options, limit: 1 });
74
+ return matches[0] ?? null;
75
+ }
76
+ /**
77
+ * Server-side tag search.
78
+ *
79
+ * Two honesty notes:
80
+ *
81
+ * - Modal's `list` yields sandboxes carrying *at least* the requested tags,
82
+ * so the ownership tag is merged into the filter rather than applied after
83
+ * the fact. A foreign sandbox is never fetched, let alone returned.
84
+ * - Modal's `Sandbox` object carries **no state field**. The only way to
85
+ * learn whether one is running is `poll()`, one round trip each. So state
86
+ * is left `undefined` unless the caller actually asked to filter on it,
87
+ * in which case each candidate costs one extra call. Reporting a guessed
88
+ * state for free would be worse than reporting none.
89
+ */
90
+ async findAllByLabels(labels, options = {}) {
91
+ const tags = this.buildLookupTags(labels, options.owned);
92
+ const limit = normalizePositiveInt(options.limit) ?? Number.POSITIVE_INFINITY;
93
+ if (limit === 0) {
94
+ return [];
95
+ }
96
+ const excluded = new Set(options.excludeIds ?? []);
97
+ const states = options.states ?? null;
98
+ const deadline = this.deadline(options.timeoutMs ?? this.options.lookupTimeoutMs, "findAllByLabels");
99
+ const context = await this.context(deadline);
100
+ const matches = [];
101
+ const iterator = context.client.sandboxes
102
+ .list(this.listParams(context, tags))[Symbol.asyncIterator]();
103
+ // `for await` awaits `iterator.next()` outside `deadline.run`, so a stalled
104
+ // list page can outlive `timeoutMs` and the caller's budget stops meaning
105
+ // anything. Drive the iterator by hand and wrap each fetch in the deadline.
106
+ for (;;) {
107
+ const step = await deadline.run(Promise.resolve(iterator.next()));
108
+ if (step.done) {
109
+ break;
110
+ }
111
+ const sandbox = step.value;
112
+ if (excluded.has(sandbox.sandboxId)) {
113
+ continue;
114
+ }
115
+ if (!(await this.tagsReallyMatch(sandbox, tags, deadline))) {
116
+ continue;
117
+ }
118
+ if (states === null) {
119
+ matches.push(this.toHandle(sandbox.sandboxId));
120
+ }
121
+ else {
122
+ const state = await deadline.run(sandbox.poll().then(exitCodeToState));
123
+ if (!states.includes(state)) {
124
+ continue;
125
+ }
126
+ matches.push(this.toHandle(sandbox.sandboxId, state));
127
+ }
128
+ if (matches.length >= limit) {
129
+ break;
130
+ }
131
+ }
132
+ return matches;
133
+ }
134
+ async countByLabels(labels, options = {}) {
135
+ const tags = this.buildLookupTags(labels, true);
136
+ const states = options.states ?? null;
137
+ // `maxCount` is an early exit for the *count*: stop paging once the caller
138
+ // has learned enough, and report exactly what was counted. `limit` is a
139
+ // page-size hint elsewhere in the port and treating it as a ceiling here
140
+ // would silently undercount matching sandboxes.
141
+ const ceiling = normalizePositiveInt(options.maxCount) ?? Number.POSITIVE_INFINITY;
142
+ if (ceiling === 0) {
143
+ return 0;
144
+ }
145
+ const deadline = this.deadline(options.timeoutMs ?? this.options.lookupTimeoutMs, "countByLabels");
146
+ const context = await this.context(deadline);
147
+ let count = 0;
148
+ const iterator = context.client.sandboxes
149
+ .list(this.listParams(context, tags))[Symbol.asyncIterator]();
150
+ // Same reason as findAllByLabels: the iterator advance must sit inside the
151
+ // deadline or a stalled page defeats the caller's timeoutMs.
152
+ for (;;) {
153
+ const step = await deadline.run(Promise.resolve(iterator.next()));
154
+ if (step.done) {
155
+ break;
156
+ }
157
+ const sandbox = step.value;
158
+ if (!(await this.tagsReallyMatch(sandbox, tags, deadline))) {
159
+ continue;
160
+ }
161
+ if (states !== null) {
162
+ const state = await deadline.run(sandbox.poll().then(exitCodeToState));
163
+ if (!states.includes(state)) {
164
+ continue;
165
+ }
166
+ }
167
+ count += 1;
168
+ if (count >= ceiling) {
169
+ break;
170
+ }
171
+ }
172
+ return count;
173
+ }
174
+ /**
175
+ * Re-resolve a sandbox by id.
176
+ *
177
+ * Ownership is re-checked here, not assumed. `fromId` will happily return any
178
+ * sandbox in the workspace, so a caller holding a synthesized or stale id
179
+ * could otherwise reattach to a sandbox belonging to another lane. A foreign
180
+ * sandbox reads as absent (`null`) rather than raising, because "not one of
181
+ * mine" and "not there" are the same answer to this question.
182
+ *
183
+ * Unlike `findAllByLabels`/`countByLabels`, this path is not server-side
184
+ * tag-filtered: Modal's `fromId` accepts no tag argument, so a foreign
185
+ * sandbox is fetched before `isOwned()` can reject it. That costs one extra
186
+ * `getTags()` round trip for a lookup a lane could otherwise have skipped;
187
+ * the trade is deliberate because `fromId` is the only reattach primitive
188
+ * Modal exposes.
189
+ */
190
+ async getById(id, options = {}) {
191
+ const sandboxId = id?.trim();
192
+ if (!sandboxId) {
193
+ return null;
194
+ }
195
+ const deadline = this.deadline(this.options.lookupTimeoutMs, "getById");
196
+ const context = await this.context(deadline);
197
+ let sandbox;
198
+ try {
199
+ sandbox = await deadline.run(context.client.sandboxes.fromId(sandboxId));
200
+ }
201
+ catch (error) {
202
+ if (isNotFound(error)) {
203
+ return null;
204
+ }
205
+ throw error;
206
+ }
207
+ if (options.owned !== false && !(await this.isOwned(sandbox, deadline))) {
208
+ return null;
209
+ }
210
+ const states = options.states ?? null;
211
+ if (states === null) {
212
+ return this.toHandle(sandbox.sandboxId);
213
+ }
214
+ const state = await deadline.run(sandbox.poll().then(exitCodeToState));
215
+ return states.includes(state) ? this.toHandle(sandbox.sandboxId, state) : null;
216
+ }
217
+ // --- lifecycle ----------------------------------------------------------
218
+ /**
219
+ * Create a sandbox.
220
+ *
221
+ * `createTimeoutSeconds` is a **client-side deadline on this call**. It is
222
+ * deliberately NOT forwarded to Modal's `timeoutMs`, which despite the name
223
+ * is the sandbox's maximum lifetime. Conflating them would make a caller who
224
+ * asked to wait 30 s for provisioning receive a sandbox that self-destructs
225
+ * 30 s later. Lifetime comes from `maxLifetimeMs` config and nowhere else.
226
+ */
227
+ async launch(options = {}) {
228
+ const tags = this.buildOwnedTags(options.labels ?? {});
229
+ const timeoutMs = options.createTimeoutSeconds === undefined
230
+ ? this.options.createTimeoutMs
231
+ : secondsToMs(options.createTimeoutSeconds, "createTimeoutSeconds");
232
+ const deadline = this.deadline(timeoutMs, "launch");
233
+ const context = await this.context(deadline);
234
+ const params = {
235
+ name: this.sandboxName(options.name),
236
+ tags,
237
+ workdir: this.options.workdir,
238
+ // Always explicit. Omitting this is how a caller silently inherits
239
+ // Modal's 5-minute cap.
240
+ timeoutMs: this.options.maxLifetimeMs,
241
+ ...(this.options.idleTimeoutMs === undefined
242
+ ? {}
243
+ : { idleTimeoutMs: this.options.idleTimeoutMs }),
244
+ ...(options.env === undefined ? {} : { env: options.env }),
245
+ ...(this.options.resources?.cpu === undefined ? {} : { cpu: this.options.resources.cpu }),
246
+ ...(this.options.resources?.cpuLimit === undefined
247
+ ? {}
248
+ : { cpuLimit: this.options.resources.cpuLimit }),
249
+ ...(this.options.resources?.memoryMiB === undefined
250
+ ? {}
251
+ : { memoryMiB: this.options.resources.memoryMiB }),
252
+ ...(this.options.resources?.memoryLimitMiB === undefined
253
+ ? {}
254
+ : { memoryLimitMiB: this.options.resources.memoryLimitMiB }),
255
+ ...(this.options.cloud === undefined ? {} : { cloud: this.options.cloud }),
256
+ ...(this.options.regions === undefined ? {} : { regions: this.options.regions }),
257
+ ...(this.options.blockNetwork === undefined
258
+ ? {}
259
+ : { blockNetwork: this.options.blockNetwork }),
260
+ };
261
+ // Hold the raw create promise so it outlives the deadline race. If the
262
+ // caller's client-side budget fires before Modal responds, the create can
263
+ // still complete and hand back a billed sandbox with no handle in reach.
264
+ // Reconcile that outcome in the background — see `trackReconciliation`.
265
+ const pending = context.client.sandboxes.create(context.app, context.image, params);
266
+ let sandbox;
267
+ try {
268
+ sandbox = await deadline.run(pending);
269
+ }
270
+ catch (error) {
271
+ if (error instanceof ModalDeadlineExceededError) {
272
+ this.trackReconciliation(this.reconcileOrphanedCreate(pending));
273
+ }
274
+ throw error;
275
+ }
276
+ return this.toHandle(sandbox.sandboxId, "running");
277
+ }
278
+ /**
279
+ * Terminate a create that outlived its deadline.
280
+ *
281
+ * Runs after `launch()` has already rejected, so the caller has no handle
282
+ * for the sandbox that might still be materialising. Failure modes here are
283
+ * all silent by design: if the create ultimately rejects there is nothing to
284
+ * clean up; if terminate fails there is no caller to raise to. `close()`
285
+ * awaits these before releasing the gRPC channel so a reconciliation cannot
286
+ * be orphaned by teardown.
287
+ */
288
+ async reconcileOrphanedCreate(pending) {
289
+ let sandbox;
290
+ try {
291
+ sandbox = await pending;
292
+ }
293
+ catch {
294
+ return;
295
+ }
296
+ try {
297
+ await sandbox.terminate({ wait: false });
298
+ }
299
+ catch {
300
+ // The next label sweep — either the caller's or this runtime's cleanup —
301
+ // is the safety net. There is no useful action to take from here.
302
+ }
303
+ }
304
+ trackReconciliation(work) {
305
+ this.reconciliations.add(work);
306
+ void work.finally(() => this.reconciliations.delete(work));
307
+ }
308
+ /**
309
+ * Terminate a sandbox and verify it is gone.
310
+ *
311
+ * Ownership is checked before the terminate, not after. `destroy` is
312
+ * irreversible, so paying one extra round trip to be certain the sandbox is
313
+ * ours is the correct trade — a mis-routed handle must fail loudly rather
314
+ * than delete another lane's work.
315
+ *
316
+ * Verification prefers Modal's own signal: `terminate({ wait: true })`
317
+ * resolves with an exit code only once the sandbox has actually finished. If
318
+ * the provider resolves without one, the adapter falls back to polling until
319
+ * `poll()` reports a finished sandbox, so `cleanupVerified` never rests on a
320
+ * request merely having been accepted.
321
+ */
322
+ async destroy(handle) {
323
+ const sandboxId = requireHandleId(handle);
324
+ const deadline = this.deadline(this.options.destroyTimeoutMs, "destroy");
325
+ const context = await this.context(deadline);
326
+ let sandbox;
327
+ try {
328
+ sandbox = await deadline.run(context.client.sandboxes.fromId(sandboxId));
329
+ }
330
+ catch (error) {
331
+ // Already absent is success: destroy is idempotent by contract.
332
+ if (isNotFound(error)) {
333
+ return;
334
+ }
335
+ throw error;
336
+ }
337
+ if (!(await this.isOwned(sandbox, deadline))) {
338
+ throw new ModalForeignSandboxError(sandboxId, this.options.namePrefix);
339
+ }
340
+ const settled = await deadline.run(sandbox.terminate({ wait: true }));
341
+ if (typeof settled === "number") {
342
+ return;
343
+ }
344
+ await this.waitUntilGone(sandbox, deadline);
345
+ }
346
+ // --- files --------------------------------------------------------------
347
+ async uploadBundle(handle, options) {
348
+ const sandboxId = requireHandleId(handle);
349
+ if (options.files.length === 0) {
350
+ return;
351
+ }
352
+ const deadline = this.deadline(this.options.uploadTimeoutMs, "uploadBundle");
353
+ const context = await this.context(deadline);
354
+ const sandbox = await this.ownedSandbox(sandboxId, context, deadline);
355
+ // Sequential on purpose. Modal's filesystem writes go through the same
356
+ // per-sandbox command router; firing a bundle in parallel buys little and
357
+ // makes a partial failure much harder to attribute to a file.
358
+ for (const file of options.files) {
359
+ deadline.assertNotExpired();
360
+ const destination = file.destination?.trim();
361
+ if (!destination) {
362
+ throw new Error("ModalRuntime uploadBundle requires a non-empty destination for every file");
363
+ }
364
+ if (!destination.startsWith("/")) {
365
+ // Modal requires absolute remote paths and rejects anything else
366
+ // server-side; failing here names the offending file.
367
+ throw new Error(`ModalRuntime uploadBundle requires an absolute destination path; got "${destination}"`);
368
+ }
369
+ const bytes = typeof file.source === "string"
370
+ ? new Uint8Array(Buffer.from(file.source, "utf8"))
371
+ : new Uint8Array(file.source);
372
+ await deadline.run(sandbox.filesystem.writeBytes(bytes, destination));
373
+ }
374
+ }
375
+ // --- exec ---------------------------------------------------------------
376
+ /**
377
+ * Run a command to completion and return its buffered output.
378
+ *
379
+ * Modal's `exec` takes an **argv array**, not a shell string, so the caller's
380
+ * command is wrapped in `sh -lc`. Without that wrapping every pipeline,
381
+ * redirect, and variable expansion a caller writes would be passed through as
382
+ * a literal argument.
383
+ *
384
+ * stdout, stderr, and the exit code are awaited together. Draining the
385
+ * streams only after `wait()` resolves risks deadlocking against a process
386
+ * that fills a pipe buffer and blocks before exiting.
387
+ */
388
+ async runScript(handle, options) {
389
+ const sandboxId = requireHandleId(handle);
390
+ const command = options.command?.trim();
391
+ if (!command) {
392
+ throw new Error("ModalRuntime runScript requires a non-empty command");
393
+ }
394
+ const timeoutMs = options.timeoutMs ?? this.options.execTimeoutMs;
395
+ const deadline = this.deadline(timeoutMs, "runScript");
396
+ const context = await this.context(deadline);
397
+ const sandbox = await this.ownedSandbox(sandboxId, context, deadline);
398
+ const process = await deadline.run(sandbox.exec(["sh", "-lc", command], {
399
+ workdir: handle.workdir ?? this.options.workdir,
400
+ // Hand Modal the same budget, so the provider kills a runaway command
401
+ // even if this client goes away. The outer deadline is a second line of
402
+ // defence, not the only one.
403
+ timeoutMs,
404
+ stdout: "pipe",
405
+ stderr: "pipe",
406
+ ...(options.env === undefined ? {} : { env: options.env }),
407
+ }));
408
+ const [stdout, stderr, exitCode] = await deadline.run(Promise.all([
409
+ process.stdout.readText(),
410
+ process.stderr.readText(),
411
+ process.wait(),
412
+ ]));
413
+ return {
414
+ output: stdout + stderr,
415
+ stdout,
416
+ stderr,
417
+ exitCode,
418
+ // Modal reports no truncation signal on these streams. `undefined` means
419
+ // "not reported", never "known complete", so it is left off entirely.
420
+ };
421
+ }
422
+ // --- internals ----------------------------------------------------------
423
+ /** Tags every sandbox this runtime creates must carry. */
424
+ ownerTags() {
425
+ return { [this.options.ownerTagKey]: this.options.namePrefix };
426
+ }
427
+ /**
428
+ * Merge caller labels with the ownership tag, refusing to let a caller
429
+ * overwrite it. Silently winning that collision either way is a bug: our
430
+ * value would make a foreign sandbox look owned, theirs would make ours
431
+ * invisible to cleanup.
432
+ */
433
+ buildOwnedTags(labels) {
434
+ if (Object.hasOwn(labels, this.options.ownerTagKey)) {
435
+ throw new ModalTagCollisionError(this.options.ownerTagKey);
436
+ }
437
+ return { ...labels, ...this.ownerTags() };
438
+ }
439
+ buildLookupTags(labels, owned) {
440
+ // `owned: false` is an explicit opt-out for cross-lane audits. Anything
441
+ // else — including `undefined` — scopes the search to this runtime.
442
+ if (owned === false) {
443
+ return { ...labels };
444
+ }
445
+ return this.buildOwnedTags(labels);
446
+ }
447
+ listParams(context, tags) {
448
+ return {
449
+ tags,
450
+ ...(context.app.appId === undefined ? {} : { appId: context.app.appId }),
451
+ ...(this.options.environment === undefined
452
+ ? {}
453
+ : { environment: this.options.environment }),
454
+ };
455
+ }
456
+ /**
457
+ * Confirm in-process that a listed sandbox really carries the tags we
458
+ * filtered on.
459
+ *
460
+ * The server-side filter is the mechanism this adapter's ownership rests on,
461
+ * and it has not yet been proven live — `warmLease` is still `false`. A
462
+ * filter that is silently ignored or partially applied would return a foreign
463
+ * sandbox, and handing that back as a warm lease is worse than returning
464
+ * nothing: the caller would exec into another tenant's container. So the
465
+ * claim is re-checked against the sandbox's own tags rather than trusted.
466
+ *
467
+ * Skipped when there is nothing to check, so an unfiltered audit listing
468
+ * (`owned: false` with no labels) does not pay for a round trip that could
469
+ * not reject anything.
470
+ */
471
+ async tagsReallyMatch(sandbox, wanted, deadline) {
472
+ const entries = Object.entries(wanted);
473
+ if (!this.options.verifyTagsClientSide || entries.length === 0) {
474
+ return true;
475
+ }
476
+ let actual;
477
+ try {
478
+ actual = await deadline.run(sandbox.getTags());
479
+ }
480
+ catch (error) {
481
+ // A sandbox that vanished between `list` and `getTags` is a non-match,
482
+ // not a lookup failure — otherwise one racing termination would abort
483
+ // the entire scan and hide every other candidate from the caller.
484
+ if (isNotFound(error)) {
485
+ return false;
486
+ }
487
+ throw error;
488
+ }
489
+ return entries.every(([key, value]) => actual[key] === value);
490
+ }
491
+ async isOwned(sandbox, deadline) {
492
+ const tags = await deadline.run(sandbox.getTags());
493
+ return tags[this.options.ownerTagKey] === this.options.namePrefix;
494
+ }
495
+ /**
496
+ * Resolve a sandbox by id and prove ownership before returning it.
497
+ *
498
+ * Every write-or-execute path that takes a caller-supplied id must go
499
+ * through this: `fromId` will happily return any sandbox in the workspace,
500
+ * so a stale or synthesized handle would otherwise let a caller reattach to
501
+ * another lane's sandbox and mutate it. `destroy` uses its own resolution
502
+ * because it must also treat an already-absent sandbox as success — the
503
+ * only shape here that is idempotent.
504
+ */
505
+ async ownedSandbox(sandboxId, context, deadline) {
506
+ const sandbox = await deadline.run(context.client.sandboxes.fromId(sandboxId));
507
+ if (!(await this.isOwned(sandbox, deadline))) {
508
+ throw new ModalForeignSandboxError(sandboxId, this.options.namePrefix);
509
+ }
510
+ return sandbox;
511
+ }
512
+ async waitUntilGone(sandbox, deadline) {
513
+ for (;;) {
514
+ const exitCode = await deadline.run(sandbox.poll());
515
+ if (exitCode !== null) {
516
+ return;
517
+ }
518
+ deadline.assertNotExpired();
519
+ await sleep(this.options.pollIntervalMs);
520
+ }
521
+ }
522
+ sandboxName(name) {
523
+ const suffix = name?.trim() || randomUUID();
524
+ return `${this.options.namePrefix}-${suffix}`;
525
+ }
526
+ toHandle(id, state) {
527
+ return {
528
+ id,
529
+ homeDir: this.options.defaultHomeDir,
530
+ workdir: this.options.workdir,
531
+ ...(state === undefined ? {} : { state }),
532
+ };
533
+ }
534
+ deadline(totalMs, operation) {
535
+ return new Deadline(totalMs, operation, this.options.requestTimeoutMs);
536
+ }
537
+ client() {
538
+ this.clientPromise ??= Promise.resolve(this.clientFactory(this.options)).catch((error) => {
539
+ // Do not cache a rejected client: a transient construction failure would
540
+ // otherwise poison every later call for the lifetime of the runtime.
541
+ this.clientPromise = null;
542
+ throw error;
543
+ });
544
+ return this.clientPromise;
545
+ }
546
+ /**
547
+ * Resolve the App and Image once and reuse them. Both are required by every
548
+ * `create`, neither changes over the runtime's life, and each resolution is
549
+ * a round trip.
550
+ */
551
+ context(deadline) {
552
+ this.contextPromise ??= (async () => {
553
+ const client = await this.client();
554
+ const app = await client.apps.fromName(this.options.appName, {
555
+ createIfMissing: this.options.createAppIfMissing,
556
+ ...(this.options.environment === undefined
557
+ ? {}
558
+ : { environment: this.options.environment }),
559
+ });
560
+ const image = client.images.fromRegistry(this.options.imageTag);
561
+ return { client, app, image };
562
+ })().catch((error) => {
563
+ this.contextPromise = null;
564
+ throw error;
565
+ });
566
+ return deadline.run(this.contextPromise);
567
+ }
568
+ /**
569
+ * Release the gRPC channel.
570
+ *
571
+ * Not part of `SandboxRuntime`, but necessary: the SDK holds an open HTTP/2
572
+ * connection, and a Node process that never calls this will not exit on its
573
+ * own. Long-lived hosts can ignore it; short-lived scripts and tests cannot.
574
+ */
575
+ async close() {
576
+ // Drain reconciliations first: releasing the gRPC channel while an orphan
577
+ // terminate is still in flight would abandon exactly the resource this
578
+ // path exists to clean up.
579
+ if (this.reconciliations.size > 0) {
580
+ await Promise.allSettled([...this.reconciliations]);
581
+ }
582
+ const pending = this.clientPromise;
583
+ this.clientPromise = null;
584
+ this.contextPromise = null;
585
+ if (!pending) {
586
+ return;
587
+ }
588
+ try {
589
+ (await pending).close();
590
+ }
591
+ catch {
592
+ // Closing a channel that is already gone is not an error worth raising
593
+ // from a teardown path.
594
+ }
595
+ }
596
+ }
597
+ // --- errors ---------------------------------------------------------------
598
+ /** A handle that does not carry this runtime's ownership tag. */
599
+ export class ModalForeignSandboxError extends Error {
600
+ sandboxId;
601
+ expectedOwner;
602
+ constructor(sandboxId, expectedOwner) {
603
+ super(`Modal sandbox ${sandboxId} is not owned by "${expectedOwner}": refusing to operate on it`);
604
+ this.name = "ModalForeignSandboxError";
605
+ this.sandboxId = sandboxId;
606
+ this.expectedOwner = expectedOwner;
607
+ }
608
+ }
609
+ /** A caller label collided with the reserved ownership tag key. */
610
+ export class ModalTagCollisionError extends Error {
611
+ tagKey;
612
+ constructor(tagKey) {
613
+ super(`Label key "${tagKey}" is reserved for Modal sandbox ownership and cannot be set by a caller`);
614
+ this.name = "ModalTagCollisionError";
615
+ this.tagKey = tagKey;
616
+ }
617
+ }
618
+ /**
619
+ * An operation exceeded its explicit client-side deadline.
620
+ *
621
+ * Two independent caps bound any Modal call: this per-operation budget, and
622
+ * the SDK's own per-request `requestTimeoutMs`. They fail differently on
623
+ * purpose — the SDK's cap surfaces as a gRPC error from the vendor, this one
624
+ * as `ModalDeadlineExceededError` — so the error *type* already says which
625
+ * fired. The message names the sibling cap anyway, because a reader looking at
626
+ * a timeout usually needs to check both numbers before concluding the network
627
+ * was slow.
628
+ */
629
+ export class ModalDeadlineExceededError extends Error {
630
+ operation;
631
+ timeoutMs;
632
+ /** The SDK's per-request cap, when the runtime knew it. */
633
+ requestTimeoutMs;
634
+ constructor(operation, timeoutMs, requestTimeoutMs) {
635
+ super(`Modal ${operation} exceeded its ${timeoutMs}ms operation deadline`
636
+ + (requestTimeoutMs === undefined
637
+ ? ""
638
+ : ` (the SDK's per-request cap is ${requestTimeoutMs}ms and fails separately, `
639
+ + "as a gRPC error rather than this one)"));
640
+ this.name = "ModalDeadlineExceededError";
641
+ this.operation = operation;
642
+ this.timeoutMs = timeoutMs;
643
+ this.requestTimeoutMs = requestTimeoutMs;
644
+ }
645
+ }
646
+ /** Declared capabilities disagree with what this class actually implements. */
647
+ export class ModalCapabilityMismatchError extends Error {
648
+ mismatches;
649
+ constructor(mismatches) {
650
+ super(`Modal adapter capability reconciliation failed:\n - ${mismatches.join("\n - ")}`);
651
+ this.name = "ModalCapabilityMismatchError";
652
+ this.mismatches = mismatches;
653
+ }
654
+ }
655
+ // --- capability reconciliation --------------------------------------------
656
+ /**
657
+ * Assert at construction time that every declared capability matches what this
658
+ * class actually implements.
659
+ *
660
+ * The point is to fail on the developer's machine rather than in production.
661
+ * A declaration and an implementation drift apart silently — someone adds a
662
+ * `stop` that no-ops, or flips `warmLease` to true while `findAllByLabels`
663
+ * still returns `[]` — and the orchestrator believes the declaration. Every
664
+ * rule below encodes a way this adapter could lie.
665
+ */
666
+ export function reconcileModalCapabilities(runtime) {
667
+ const declared = runtime.declaredCapabilities ?? {};
668
+ const mismatches = [];
669
+ const has = (method) => typeof runtime[method] === "function";
670
+ // lifecycle: declaring it true while start/stop are absent would tell the
671
+ // orchestrator it can suspend a sandbox that has no suspend.
672
+ if (declared.lifecycle === true && !(has("start") && has("stop"))) {
673
+ mismatches.push("declaredCapabilities.lifecycle is true but start/stop are not implemented "
674
+ + "(Modal exposes no stop/start for a Sandbox; terminate is terminal)");
675
+ }
676
+ // Scoped to Modal on purpose, and NOT a general rule.
677
+ //
678
+ // For most providers, `start`/`stop` present with `lifecycle: false` is the
679
+ // legitimate shape of a real implementation awaiting a live probe — the
680
+ // orchestrator reads the declaration, not method presence, so the under-claim
681
+ // is safe and is the only way a capability can wait for evidence. Forbidding
682
+ // it would make evidence-gating impossible.
683
+ //
684
+ // Modal is the narrow case where the check still holds: the SDK exposes no
685
+ // stop/start for a Sandbox *at all*, so a `start`/`stop` appearing on this
686
+ // class could not be an unproven implementation. It could only be a
687
+ // fabrication or a no-op, and either would tell the orchestrator a Modal
688
+ // sandbox can be suspended when nothing can suspend it.
689
+ if (declared.lifecycle !== true && (has("start") || has("stop"))) {
690
+ mismatches.push("declaredCapabilities.lifecycle is not true but start/stop are implemented. "
691
+ + "Modal exposes no stop/start for a Sandbox, so these cannot be a real "
692
+ + "implementation awaiting a live probe — only a no-op or a fabrication. "
693
+ + "(This check is Modal-specific: elsewhere, methods present with "
694
+ + "lifecycle:false is the correct way to gate a capability on evidence.)");
695
+ }
696
+ // warmLease: a true declaration promises real server-side label search.
697
+ if (declared.warmLease === true && !has("findAllByLabels")) {
698
+ mismatches.push("declaredCapabilities.warmLease is true but findAllByLabels is not implemented");
699
+ }
700
+ // asyncExec is all-or-nothing. A partial trio is the exact shape that lets a
701
+ // caller submit a command it can never poll or reap.
702
+ const asyncParts = [
703
+ ["startScript", has("startScript")],
704
+ ["getScriptStatus", has("getScriptStatus")],
705
+ ["getScriptLogs", has("getScriptLogs")],
706
+ ["getById", has("getById")],
707
+ ];
708
+ const present = asyncParts.filter(([, ok]) => ok).map(([name]) => name);
709
+ const execParts = asyncParts.slice(0, 3);
710
+ const execPresent = execParts.filter(([, ok]) => ok);
711
+ if (execPresent.length > 0 && execPresent.length < execParts.length) {
712
+ mismatches.push(`async exec is all-or-nothing but only [${present.join(", ")}] are implemented: `
713
+ + "Modal cannot re-resolve a ContainerProcess by id, so the trio must stay absent");
714
+ }
715
+ // --- modes ---------------------------------------------------------------
716
+ //
717
+ // The booleans above can only be wrong about *whether* a capability exists.
718
+ // The modes can be wrong about its *shape*, which is the subtler lie: a mode
719
+ // that over-claims reads as settled fact rather than as a pending cell, and
720
+ // `isPendingEvidence()` reports false for it, so nothing downstream will ever
721
+ // revisit it. Each rule below is a shape this adapter could misstate.
722
+ const modes = runtime.declaredCapabilityModes ?? {};
723
+ // Modal terminates every sandbox at a deadline that always exists — five
724
+ // minutes by default, MODAL_MAX_LIFETIME_MS at the ceiling. "never-idle" is
725
+ // the one lifetime Modal structurally cannot offer, which is exactly why
726
+ // `neverIdle` sits in MODAL_STRUCTURALLY_FALSE.
727
+ if (modes.lifetime === "never-idle") {
728
+ mismatches.push('declaredCapabilityModes.lifetime is "never-idle" but every Modal Sandbox '
729
+ + "carries a maximum lifetime the provider enforces (see "
730
+ + "MODAL_STRUCTURALLY_FALSE.neverIdle); there is no no-deadline setting");
731
+ }
732
+ // A live-stream mode promises callers an incremental channel. `runScript`
733
+ // drains both pipes with readText() before returning, and the port exposes no
734
+ // streaming surface at all, so any streaming member is a claim this adapter
735
+ // cannot honor no matter what the provider can do.
736
+ if (modes.outputStreams === "combined-stream"
737
+ || modes.outputStreams === "separate-streams") {
738
+ mismatches.push(`declaredCapabilityModes.outputStreams is "${modes.outputStreams}" but runScript `
739
+ + "drains stdout/stderr to completion before returning and the port exposes no "
740
+ + 'streaming operation; the honest value is "buffered"');
741
+ }
742
+ // Filesystem persistence means surviving a stop/start of the same sandbox.
743
+ // Modal has no such pair, so "persistent" cannot be true here.
744
+ if (modes.filesystem === "persistent") {
745
+ mismatches.push('declaredCapabilityModes.filesystem is "persistent" but Modal has no stop/start '
746
+ + "pair for state to survive across; terminate is the only transition and it is "
747
+ + "terminal");
748
+ }
749
+ // PTY and snapshots are real on Modal and unreachable through this port. That
750
+ // asymmetry is the whole reason "not-exposed" exists, and it is the pair most
751
+ // likely to be "corrected" upward by someone reading the provider's docs
752
+ // instead of this package's port.
753
+ if (modes.interactive === "pty" && !has("startScript")) {
754
+ mismatches.push('declaredCapabilityModes.interactive is "pty" but this package\'s port declares no '
755
+ + 'PTY operation; Modal supports pty:true, so the honest value is "not-exposed" '
756
+ + '(a fact about our port) rather than a positive claim');
757
+ }
758
+ if (modes.snapshots === "snapshot" || modes.snapshots === "snapshot-and-fork") {
759
+ mismatches.push(`declaredCapabilityModes.snapshots is "${modes.snapshots}" but this package's port `
760
+ + "declares no snapshot operation; Modal has snapshotFilesystem/snapshotDirectory, "
761
+ + 'so the honest value is "not-exposed" rather than a positive claim');
762
+ }
763
+ // The port's own resolver is the final authority; make sure it agrees.
764
+ const required = [
765
+ "findByLabels",
766
+ "findAllByLabels",
767
+ "countByLabels",
768
+ "launch",
769
+ "uploadBundle",
770
+ "runScript",
771
+ "destroy",
772
+ ];
773
+ for (const method of required) {
774
+ if (!has(method)) {
775
+ mismatches.push(`SandboxRuntime requires ${method} but it is not implemented`);
776
+ }
777
+ }
778
+ if (mismatches.length > 0) {
779
+ throw new ModalCapabilityMismatchError(mismatches);
780
+ }
781
+ }
782
+ // --- helpers --------------------------------------------------------------
783
+ /**
784
+ * An absolute client-side budget for one logical operation.
785
+ *
786
+ * Racing a promise does not cancel the work behind it — the SDK's own
787
+ * `timeoutMs` handles the wire. This bounds the *caller's* wait so a hung
788
+ * provider cannot stall an operation indefinitely, and it is shared across
789
+ * every round trip an operation makes rather than being reset per call.
790
+ */
791
+ class Deadline {
792
+ totalMs;
793
+ operation;
794
+ requestTimeoutMs;
795
+ expiresAt;
796
+ constructor(totalMs, operation, requestTimeoutMs) {
797
+ this.totalMs = totalMs;
798
+ this.operation = operation;
799
+ this.requestTimeoutMs = requestTimeoutMs;
800
+ this.expiresAt = Date.now() + totalMs;
801
+ }
802
+ expired() {
803
+ return new ModalDeadlineExceededError(this.operation, this.totalMs, this.requestTimeoutMs);
804
+ }
805
+ remainingMs() {
806
+ return this.expiresAt - Date.now();
807
+ }
808
+ assertNotExpired() {
809
+ if (this.remainingMs() <= 0) {
810
+ throw this.expired();
811
+ }
812
+ }
813
+ async run(work) {
814
+ const remaining = this.remainingMs();
815
+ if (remaining <= 0) {
816
+ throw this.expired();
817
+ }
818
+ let timer;
819
+ try {
820
+ return await Promise.race([
821
+ work,
822
+ new Promise((_resolve, reject) => {
823
+ timer = setTimeout(() => reject(this.expired()), remaining);
824
+ }),
825
+ ]);
826
+ }
827
+ finally {
828
+ if (timer !== undefined) {
829
+ clearTimeout(timer);
830
+ }
831
+ }
832
+ }
833
+ }
834
+ function sleep(ms) {
835
+ return new Promise((resolve) => setTimeout(resolve, ms));
836
+ }
837
+ /** Modal reports no state enum; a finished sandbox is one with an exit code. */
838
+ function exitCodeToState(exitCode) {
839
+ return exitCode === null ? "running" : "terminated";
840
+ }
841
+ function requireHandleId(handle) {
842
+ const id = handle?.id?.trim();
843
+ if (!id) {
844
+ throw new Error("ModalRuntime requires a handle carrying a non-empty sandbox id");
845
+ }
846
+ return id;
847
+ }
848
+ function normalizePositiveInt(value) {
849
+ if (value === undefined) {
850
+ return undefined;
851
+ }
852
+ if (!Number.isFinite(value) || value < 0) {
853
+ throw new Error(`ModalRuntime expected a finite, non-negative count; got ${String(value)}`);
854
+ }
855
+ return Math.floor(value);
856
+ }
857
+ function secondsToMs(seconds, field) {
858
+ if (!Number.isFinite(seconds) || seconds <= 0) {
859
+ throw new Error(`ModalRuntime ${field} must be a finite, positive number of seconds; got ${String(seconds)}`);
860
+ }
861
+ return seconds * 1000;
862
+ }
863
+ /**
864
+ * Modal raises `NotFoundError` by name. Matching on the name rather than on an
865
+ * imported class keeps this module free of the vendor SDK, which is the whole
866
+ * point of the internal/sdk.ts boundary.
867
+ */
868
+ function isNotFound(error) {
869
+ return error instanceof Error && error.name === "NotFoundError";
870
+ }
871
+ //# sourceMappingURL=runtime.js.map