@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,1169 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { vercelCapabilityModes, vercelObservedCapabilities, vercelSandboxCapabilities, vercelWorkflowCapabilities, } from "./capabilities.js";
5
+ import { createOfficialVercelSandboxApi, currentRequestDeadlineSignal, isVercelNotFoundError, withRequestDeadline, } from "./internal/sdk.js";
6
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
7
+ const DEFAULT_CREATE_TIMEOUT_MS = 180_000;
8
+ const DEFAULT_LOOKUP_TIMEOUT_MS = 15_000;
9
+ const DEFAULT_EXEC_TIMEOUT_MS = 120_000;
10
+ const DEFAULT_FILE_TIMEOUT_MS = 60_000;
11
+ const DEFAULT_LIFECYCLE_TIMEOUT_MS = 120_000;
12
+ const DEFAULT_DELETE_TIMEOUT_MS = 120_000;
13
+ const DEFAULT_POLL_INTERVAL_MS = 750;
14
+ const DEFAULT_RETRY_DEADLINE_MS = 180_000;
15
+ const DEFAULT_LIST_PAGE_SIZE = 100;
16
+ const DEFAULT_MAX_LIST_PAGES = 50;
17
+ /** The provider caps tags per sandbox; exceeding it is rejected server-side. */
18
+ const MAX_TAGS = 5;
19
+ /** The provider caps exposed ports per sandbox; exceeding it is rejected. */
20
+ const MAX_PORTS = 15;
21
+ /**
22
+ * Conservative client-side bound on generated sandbox names. The provider's
23
+ * exact limit is not documented in the SDK types, so the adapter stays well
24
+ * inside any plausible one rather than discovering it as a create failure.
25
+ */
26
+ const MAX_NAME_LENGTH = 63;
27
+ /**
28
+ * Room the adapter reserves for its own generated suffix on a bare launch:
29
+ * `<prefix>-<16-hex-chars>`. Validated up front so a prefix that fits the raw
30
+ * name budget but not the generated one cannot slip past into a launch that
31
+ * only fails at the provider.
32
+ */
33
+ const GENERATED_SUFFIX_LENGTH = 17;
34
+ export class VercelOperationTimeoutError extends Error {
35
+ operation;
36
+ timeoutMs;
37
+ constructor(operation, timeoutMs) {
38
+ super(`Vercel ${operation} did not complete within ${timeoutMs}ms`);
39
+ this.operation = operation;
40
+ this.timeoutMs = timeoutMs;
41
+ this.name = "VercelOperationTimeoutError";
42
+ }
43
+ }
44
+ export class VercelLifecycleTimeoutError extends Error {
45
+ sandboxName;
46
+ expected;
47
+ lastState;
48
+ timeoutMs;
49
+ constructor(sandboxName, expected, lastState, timeoutMs) {
50
+ super(`Vercel sandbox "${sandboxName}" did not reach ${expected} within ${timeoutMs}ms `
51
+ + `(last state: "${lastState}")`);
52
+ this.sandboxName = sandboxName;
53
+ this.expected = expected;
54
+ this.lastState = lastState;
55
+ this.timeoutMs = timeoutMs;
56
+ this.name = "VercelLifecycleTimeoutError";
57
+ }
58
+ }
59
+ export class VercelDestroyVerificationError extends Error {
60
+ sandboxName;
61
+ timeoutMs;
62
+ constructor(sandboxName, timeoutMs) {
63
+ super(`Vercel sandbox "${sandboxName}" delete was accepted but absence was not verified `
64
+ + `within ${timeoutMs}ms`);
65
+ this.sandboxName = sandboxName;
66
+ this.timeoutMs = timeoutMs;
67
+ this.name = "VercelDestroyVerificationError";
68
+ }
69
+ }
70
+ export class VercelUnknownExitCodeError extends Error {
71
+ cmdId;
72
+ constructor(cmdId) {
73
+ super(`Vercel command "${cmdId}" reported no exit code; refusing to treat it as success`);
74
+ this.cmdId = cmdId;
75
+ this.name = "VercelUnknownExitCodeError";
76
+ }
77
+ }
78
+ export class VercelForeignSandboxError extends Error {
79
+ sandboxName;
80
+ namePrefix;
81
+ constructor(sandboxName, namePrefix) {
82
+ super(`Vercel sandbox "${sandboxName}" is outside the owned prefix "${namePrefix}" `
83
+ + "and will not be mutated by this runtime");
84
+ this.sandboxName = sandboxName;
85
+ this.namePrefix = namePrefix;
86
+ this.name = "VercelForeignSandboxError";
87
+ }
88
+ }
89
+ export class VercelDetachedLaunchUnsupportedError extends Error {
90
+ constructor() {
91
+ super("Vercel Sandbox.create resolves only once the sandbox is running; there is no "
92
+ + "mid-boot handle to hand back, so detached launch is not offered");
93
+ this.name = "VercelDetachedLaunchUnsupportedError";
94
+ }
95
+ }
96
+ export class VercelTagLimitError extends Error {
97
+ count;
98
+ constructor(count) {
99
+ super(`Vercel sandboxes accept at most ${MAX_TAGS} tags; received ${count}`);
100
+ this.count = count;
101
+ this.name = "VercelTagLimitError";
102
+ }
103
+ }
104
+ export class VercelListPageLimitError extends Error {
105
+ maxPages;
106
+ operation;
107
+ constructor(maxPages, operation) {
108
+ super(`Vercel ${operation} exceeded the ${maxPages}-page listing cap; refusing to return `
109
+ + "a silently truncated result");
110
+ this.maxPages = maxPages;
111
+ this.operation = operation;
112
+ this.name = "VercelListPageLimitError";
113
+ }
114
+ }
115
+ export class VercelCapabilityMismatchError extends Error {
116
+ constructor(message) {
117
+ super(`Vercel capability declaration mismatch: ${message}`);
118
+ this.name = "VercelCapabilityMismatchError";
119
+ }
120
+ }
121
+ /**
122
+ * One budget for one logical operation, shared across every round trip it makes.
123
+ *
124
+ * The trap this closes: an operation built from four calls that each carry a
125
+ * 30-second timeout can legitimately take two minutes, which is not what any
126
+ * caller passing "30 seconds" meant. A `Deadline` fixes `expiresAt` once, and
127
+ * every subsequent call draws from what is left rather than starting fresh.
128
+ *
129
+ * `totalMs` is what the error reports, because that is the promise that was
130
+ * broken — not the sliver of it that happened to be left when the clock ran out.
131
+ */
132
+ class Deadline {
133
+ totalMs;
134
+ operation;
135
+ expiresAt;
136
+ constructor(totalMs, operation) {
137
+ this.totalMs = totalMs;
138
+ this.operation = operation;
139
+ this.expiresAt = Date.now() + totalMs;
140
+ }
141
+ /** Milliseconds left, floored at zero. */
142
+ remaining() {
143
+ return Math.max(0, this.expiresAt - Date.now());
144
+ }
145
+ /** Milliseconds left, or throw if the budget is already spent. */
146
+ require() {
147
+ const left = this.remaining();
148
+ if (left <= 0) {
149
+ throw new VercelOperationTimeoutError(this.operation, this.totalMs);
150
+ }
151
+ return left;
152
+ }
153
+ expired() {
154
+ return this.remaining() <= 0;
155
+ }
156
+ }
157
+ /**
158
+ * Vercel Sandbox provider adapter.
159
+ *
160
+ * Identity note, and it is load-bearing: a Vercel sandbox is addressed by its
161
+ * **name**, unique within a project, not by an opaque server id. `RuntimeHandle.id`
162
+ * therefore carries the sandbox name. That is what makes prefix ownership a real
163
+ * boundary here rather than a labelling convention — the adapter will not mutate
164
+ * a sandbox whose name falls outside its prefix.
165
+ */
166
+ export class VercelSandboxRuntime {
167
+ id = "vercel";
168
+ capabilities = vercelWorkflowCapabilities;
169
+ declaredCapabilities = vercelSandboxCapabilities;
170
+ declaredCapabilityModes = vercelCapabilityModes;
171
+ observedCapabilities = vercelObservedCapabilities;
172
+ credentials;
173
+ namePrefix;
174
+ defaultHomeDir;
175
+ source;
176
+ vcpus;
177
+ sandboxTimeoutMs;
178
+ persistent;
179
+ ports;
180
+ env;
181
+ requestTimeoutMs;
182
+ createTimeoutMs;
183
+ lookupTimeoutMs;
184
+ execTimeoutMs;
185
+ fileTimeoutMs;
186
+ lifecycleTimeoutMs;
187
+ deleteTimeoutMs;
188
+ pollIntervalMs;
189
+ retryDeadlineMs;
190
+ listPageSize;
191
+ maxListPages;
192
+ injectedApiFactory;
193
+ registrations = new Map();
194
+ instances = new Map();
195
+ constructor(options, dependencies = {}) {
196
+ this.credentials = {
197
+ token: required(options.token, "Vercel token"),
198
+ teamId: required(options.teamId, "Vercel team id"),
199
+ projectId: required(options.projectId, "Vercel project id"),
200
+ };
201
+ this.namePrefix = validateNamePrefix(options.namePrefix);
202
+ this.defaultHomeDir = required(options.defaultHomeDir, "Vercel default home directory");
203
+ this.source = validateSource(options.source);
204
+ this.vcpus = validateVcpus(options.vcpus);
205
+ this.sandboxTimeoutMs = optionalPositive(options.sandboxTimeoutMs, "Vercel sandboxTimeoutMs");
206
+ this.persistent = options.persistent;
207
+ this.ports = validatePorts(options.ports);
208
+ this.env = validateEnv(options.env ?? {});
209
+ this.requestTimeoutMs = positiveDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS);
210
+ this.createTimeoutMs = positiveDuration(options.createTimeoutMs, DEFAULT_CREATE_TIMEOUT_MS);
211
+ this.lookupTimeoutMs = positiveDuration(options.lookupTimeoutMs, DEFAULT_LOOKUP_TIMEOUT_MS);
212
+ this.execTimeoutMs = positiveDuration(options.execTimeoutMs, DEFAULT_EXEC_TIMEOUT_MS);
213
+ this.fileTimeoutMs = positiveDuration(options.fileTimeoutMs, DEFAULT_FILE_TIMEOUT_MS);
214
+ this.lifecycleTimeoutMs = positiveDuration(options.lifecycleTimeoutMs, DEFAULT_LIFECYCLE_TIMEOUT_MS);
215
+ this.deleteTimeoutMs = positiveDuration(options.deleteTimeoutMs, DEFAULT_DELETE_TIMEOUT_MS);
216
+ this.pollIntervalMs = positiveDuration(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS);
217
+ this.retryDeadlineMs = positiveDuration(options.retryDeadlineMs, DEFAULT_RETRY_DEADLINE_MS);
218
+ this.listPageSize = positiveDuration(options.listPageSize, DEFAULT_LIST_PAGE_SIZE);
219
+ this.maxListPages = positiveDuration(options.maxListPages, DEFAULT_MAX_LIST_PAGES);
220
+ this.injectedApiFactory = dependencies.apiFactory;
221
+ reconcileVercelCapabilities(this);
222
+ }
223
+ // --- launch ---------------------------------------------------------------
224
+ async launch(options = {}) {
225
+ const timeoutMs = options.createTimeoutSeconds && options.createTimeoutSeconds > 0
226
+ ? Math.ceil(options.createTimeoutSeconds * 1_000)
227
+ : this.createTimeoutMs;
228
+ const name = this.ownedName(options.name?.trim() || options.label?.trim());
229
+ const labels = validateTags(options.labels ?? {});
230
+ const env = validateEnv({ ...this.env, ...(options.env ?? {}) });
231
+ const created = await this.run(timeoutMs, "create", (signal) => this.api().then((api) => api.create({
232
+ name,
233
+ ...(hasEntries(labels) ? { tags: { ...labels } } : {}),
234
+ ...(hasEntries(env) ? { env: { ...env } } : {}),
235
+ ...(this.ports ? { ports: [...this.ports] } : {}),
236
+ ...(this.sandboxTimeoutMs ? { timeout: this.sandboxTimeoutMs } : {}),
237
+ ...(this.persistent !== undefined
238
+ ? { persistent: this.persistent }
239
+ : {}),
240
+ ...(this.vcpus ? { resources: { vcpus: this.vcpus } } : {}),
241
+ ...(this.source.type === "image" ? { image: this.source.image } : {}),
242
+ ...(this.source.type === "runtime"
243
+ ? { runtime: this.source.runtime }
244
+ : {}),
245
+ signal,
246
+ })));
247
+ if (!created || typeof created.name !== "string" || !created.name.trim()) {
248
+ throw new Error("Vercel create response is missing a sandbox name");
249
+ }
250
+ this.instances.set(created.name, created);
251
+ this.register(created.name, {
252
+ owned: true,
253
+ labels,
254
+ state: normalizeState(created.status),
255
+ createdAtMs: toMillis(created.createdAt),
256
+ });
257
+ return {
258
+ id: created.name,
259
+ state: normalizeState(created.status),
260
+ homeDir: this.defaultHomeDir,
261
+ ...(options.workdir ? { workdir: options.workdir } : {}),
262
+ ...(created.createdAt ? { createdAt: toIso(created.createdAt) } : {}),
263
+ };
264
+ }
265
+ /**
266
+ * Launch a sandbox bound to the caller's scope.
267
+ *
268
+ * Ergonomic borrowed from the `await using` shape the SDK itself exposes on
269
+ * `Sandbox.create`: the sandbox is destroyed — and its destruction verified —
270
+ * when the scope exits, including on a thrown error. Cleanup you cannot
271
+ * forget is the only kind that survives a crash path.
272
+ *
273
+ * ```ts
274
+ * await using lease = await runtime.acquire({ labels: { job: "build" } });
275
+ * await runtime.exec(lease.handle, "npm test");
276
+ * ```
277
+ */
278
+ async acquire(options = {}) {
279
+ const handle = await this.launch(options);
280
+ return {
281
+ handle,
282
+ [Symbol.asyncDispose]: async () => {
283
+ await this.destroy(handle);
284
+ },
285
+ };
286
+ }
287
+ // --- lookup ---------------------------------------------------------------
288
+ async findByLabels(labels, options = {}) {
289
+ const matches = await this.findAllByLabels(labels, { ...options, limit: 1 });
290
+ return matches[0] ?? null;
291
+ }
292
+ async findAllByLabels(labels, options = {}) {
293
+ const wanted = validateTags(labels);
294
+ const limit = options.limit;
295
+ // A non-positive cap means "zero handles wanted": short-circuit before we
296
+ // spend any pages proving the obvious.
297
+ if (limit !== undefined && limit <= 0) {
298
+ return [];
299
+ }
300
+ const states = options.states === undefined ? ["STARTED"] : options.states;
301
+ const excluded = new Set(options.excludeIds ?? []);
302
+ const handles = [];
303
+ // Iterate pages one at a time and filter as they arrive: with a small
304
+ // `limit` the first candidate on page one lets us skip the rest of the
305
+ // listing entirely, instead of materialising every page and truncating.
306
+ for await (const page of this.listPages({
307
+ namePrefix: this.namePrefix,
308
+ ...(hasEntries(wanted) ? { tags: { ...wanted } } : {}),
309
+ }, options.timeoutMs ?? this.lookupTimeoutMs, "label lookup", options.pageSize)) {
310
+ for (const item of page) {
311
+ if (!this.isOwnedName(item.name) || excluded.has(item.name)) {
312
+ continue;
313
+ }
314
+ // Re-check the tags client-side. The `tags` query parameter is a
315
+ // server filter this adapter has not yet proven; if the provider were
316
+ // to ignore it, an unchecked result would hand the caller someone
317
+ // else's sandbox as a warm lease. Verifying locally costs nothing.
318
+ if (!matchesAllTags(item.tags, wanted)) {
319
+ continue;
320
+ }
321
+ if (!matchesState(item.status, states)) {
322
+ continue;
323
+ }
324
+ this.register(item.name, {
325
+ owned: options.owned ?? true,
326
+ labels: item.tags ?? {},
327
+ state: normalizeState(item.status),
328
+ createdAtMs: item.createdAt,
329
+ });
330
+ handles.push(this.toHandle(item));
331
+ if (limit !== undefined && handles.length >= limit) {
332
+ return handles;
333
+ }
334
+ }
335
+ }
336
+ return handles;
337
+ }
338
+ async countByLabels(labels, options = {}) {
339
+ const maxCount = options.maxCount;
340
+ // Zero is a legitimate ask ("do I already have any?") and must not turn
341
+ // into a full paginated listing that returns every match.
342
+ if (maxCount !== undefined && maxCount <= 0) {
343
+ return 0;
344
+ }
345
+ const effectiveLimit = maxCount ?? options.limit;
346
+ const matches = await this.findAllByLabels(labels, {
347
+ states: options.states,
348
+ ...(effectiveLimit !== undefined ? { limit: effectiveLimit } : {}),
349
+ ...(options.pageSize !== undefined ? { pageSize: options.pageSize } : {}),
350
+ ...(options.timeoutMs !== undefined
351
+ ? { timeoutMs: options.timeoutMs }
352
+ : {}),
353
+ });
354
+ return matches.length;
355
+ }
356
+ /**
357
+ * Re-resolve a sandbox by name.
358
+ *
359
+ * Deliberately implemented over `list`, not `Sandbox.get`: `get` resumes the
360
+ * session as a side effect, which starts billing and boots a VM. A lookup
361
+ * must not be able to do that — a crash-recovery scan over a hundred handles
362
+ * would otherwise wake every one of them.
363
+ */
364
+ async getById(id, options = {}) {
365
+ const item = await this.remoteByName(id, this.lookupTimeoutMs);
366
+ if (!item) {
367
+ return null;
368
+ }
369
+ const states = options.states === undefined ? null : options.states;
370
+ if (!matchesState(item.status, states)) {
371
+ return null;
372
+ }
373
+ this.register(id, {
374
+ owned: options.owned ?? false,
375
+ labels: item.tags ?? {},
376
+ state: normalizeState(item.status),
377
+ createdAtMs: item.createdAt,
378
+ });
379
+ return this.toHandle(item, options);
380
+ }
381
+ async listOwned(options = {}) {
382
+ const items = await this.listAll({ namePrefix: this.namePrefix }, options.timeoutMs ?? this.lookupTimeoutMs, "owned sandbox list");
383
+ const states = options.states === undefined ? null : options.states;
384
+ return items
385
+ .filter((item) => this.isOwnedName(item.name))
386
+ .filter((item) => options.includeTerminated || !isTerminated(item.status))
387
+ .filter((item) => matchesState(item.status, states))
388
+ .map((item) => ({
389
+ name: item.name,
390
+ state: normalizeState(item.status),
391
+ providerStatus: item.status,
392
+ createdAt: new Date(item.createdAt).toISOString(),
393
+ terminated: isTerminated(item.status),
394
+ ...(item.tags ? { tags: item.tags } : {}),
395
+ ...(typeof item.vcpus === "number" ? { vcpus: item.vcpus } : {}),
396
+ ...(typeof item.memory === "number" ? { memoryMiB: item.memory } : {}),
397
+ ...(typeof item.totalActiveCpuDurationMs === "number"
398
+ ? { activeCpuMs: item.totalActiveCpuDurationMs }
399
+ : {}),
400
+ ...(typeof item.totalDurationMs === "number"
401
+ ? { wallClockMs: item.totalDurationMs }
402
+ : {}),
403
+ }));
404
+ }
405
+ // --- exec -----------------------------------------------------------------
406
+ async runScript(handle, options) {
407
+ const timeoutMs = options.timeoutMs && options.timeoutMs > 0
408
+ ? Math.ceil(options.timeoutMs)
409
+ : this.execTimeoutMs;
410
+ // One budget for the whole call. Fetching the output is a second round
411
+ // trip, and giving it a fresh `timeoutMs` would let a caller who asked for
412
+ // 60s wait 120s.
413
+ return this.runScriptWithDeadline(handle, options, new Deadline(timeoutMs, "command"), timeoutMs);
414
+ }
415
+ /**
416
+ * Same as `runScript` but reuses an existing operation deadline.
417
+ *
418
+ * `uploadBundle` calls in from behind its own bundle-wide budget; giving the
419
+ * verification step a fresh `runScript` deadline would let a partly-consumed
420
+ * upload silently extend past its promised total. `sandboxTimeoutMs` is the
421
+ * script's sandbox-side cap and is passed through separately: the provider
422
+ * is timing the process, not our round trips.
423
+ */
424
+ async runScriptWithDeadline(handle, options, deadline, sandboxTimeoutMs) {
425
+ this.requireRegistered(handle);
426
+ const command = await this.run(deadline, "command", (signal) => this.sandbox(handle.id).then((sandbox) => sandbox.runCommand({
427
+ ...this.commandParams(options),
428
+ timeoutMs: sandboxTimeoutMs,
429
+ signal,
430
+ })));
431
+ return this.collect(command, deadline);
432
+ }
433
+ async exec(handle, command, options = {}) {
434
+ const result = await this.runScript(handle, { command, ...options });
435
+ if (result.exitCode === null) {
436
+ throw new VercelUnknownExitCodeError(result.cmdId ?? "unknown");
437
+ }
438
+ return { output: result.output, exitCode: result.exitCode };
439
+ }
440
+ // --- files ----------------------------------------------------------------
441
+ async uploadFile(handle, source, destination) {
442
+ await this.uploadBundle(handle, { files: [{ source, destination }] });
443
+ }
444
+ async uploadBundle(handle, options) {
445
+ this.requireRegistered(handle);
446
+ const files = [];
447
+ for (const file of options.files) {
448
+ const content = typeof file.source === "string"
449
+ ? await readFile(file.source)
450
+ : file.source;
451
+ files.push({ path: file.destination, content });
452
+ }
453
+ if (options.manifest !== undefined) {
454
+ files.push({
455
+ path: options.manifestPath ?? "/workspace/manifest.json",
456
+ content: Buffer.from(JSON.stringify(options.manifest, null, 2), "utf8"),
457
+ });
458
+ }
459
+ if (files.length === 0) {
460
+ return;
461
+ }
462
+ const parents = [
463
+ ...new Set(files
464
+ .map((file) => parentDirectory(file.path))
465
+ .filter((parent) => parent !== null)),
466
+ ];
467
+ // One budget across every parent mkdir, the write, and the verification.
468
+ // Per-call timeouts would make the worst case (parents + 2) x fileTimeoutMs,
469
+ // which is unbounded in the number of directories a bundle happens to span.
470
+ const deadline = new Deadline(this.fileTimeoutMs, "bundle upload");
471
+ const sandbox = await this.sandbox(handle.id);
472
+ for (const parent of parents) {
473
+ await this.run(deadline, "mkdir", (signal) => sandbox.mkDir(parent, { signal }));
474
+ }
475
+ await this.run(deadline, "file upload", (signal) => sandbox.writeFiles(files, { signal }));
476
+ // The write API reports success without telling us what landed. Verify the
477
+ // destinations exist, because a bundle that half-uploaded and reported
478
+ // success is indistinguishable from a working one until the workload fails.
479
+ // Route through the shared bundle deadline rather than starting a fresh
480
+ // `runScript` budget — the mkdirs and the write may already have consumed
481
+ // most of the caller's `fileTimeoutMs`.
482
+ const verified = await this.runScriptWithDeadline(handle, {
483
+ command: files
484
+ .map((file) => `test -f ${shellSingleQuote(file.path)}`)
485
+ .join(" && "),
486
+ }, deadline, deadline.require());
487
+ if (verified.exitCode !== 0) {
488
+ throw new Error("Failed to verify uploaded Vercel bundle files");
489
+ }
490
+ }
491
+ async downloadFile(handle, source, destination) {
492
+ this.requireRegistered(handle);
493
+ const sandbox = await this.sandbox(handle.id);
494
+ const bytes = await this.run(this.fileTimeoutMs, "file download", (signal) => sandbox.readFileToBuffer({ path: source }, { signal }));
495
+ if (bytes === null) {
496
+ throw new Error(`Vercel sandbox "${handle.id}" has no file at "${source}"`);
497
+ }
498
+ const buffer = Buffer.from(bytes);
499
+ if (destination) {
500
+ await writeFile(destination, buffer);
501
+ return;
502
+ }
503
+ return buffer;
504
+ }
505
+ async getHomeDir(handle) {
506
+ this.requireRegistered(handle);
507
+ handle.homeDir = handle.homeDir ?? this.defaultHomeDir;
508
+ return handle.homeDir;
509
+ }
510
+ // --- lifecycle ------------------------------------------------------------
511
+ async stop(handle) {
512
+ const entry = this.registrations.get(handle.id);
513
+ if (!entry || !entry.owned) {
514
+ return;
515
+ }
516
+ this.requireOwnedName(handle.id);
517
+ // Lookup, the stop call, and the settle poll all draw from one budget.
518
+ const deadline = new Deadline(this.lifecycleTimeoutMs, "stop");
519
+ const current = await this.requireRemote(handle.id, deadline);
520
+ if (isStopped(current.status)) {
521
+ entry.state = "STOPPED";
522
+ handle.state = "STOPPED";
523
+ return;
524
+ }
525
+ if (!isStopping(current.status)) {
526
+ const sandbox = await this.sandbox(handle.id);
527
+ await this.run(deadline, "stop", (signal) => sandbox.stop({ signal }));
528
+ }
529
+ await this.waitForSettled(handle.id, isStopped, "stopped", deadline);
530
+ entry.state = "STOPPED";
531
+ handle.state = "STOPPED";
532
+ }
533
+ /**
534
+ * Resume a stopped sandbox.
535
+ *
536
+ * Vercel has no explicit start call: `Sandbox.get({ resume: true })` is the
537
+ * resume path, and it is the one place this adapter *wants* that side effect.
538
+ */
539
+ async start(handle) {
540
+ const entry = this.registrations.get(handle.id);
541
+ if (!entry || !entry.owned) {
542
+ return handle;
543
+ }
544
+ this.requireOwnedName(handle.id);
545
+ // Up to four round trips — lookup, settle-if-stopping, resume, settle — so
546
+ // one budget is the only honest reading of `lifecycleTimeoutMs`.
547
+ const deadline = new Deadline(this.lifecycleTimeoutMs, "resume");
548
+ const current = await this.requireRemote(handle.id, deadline);
549
+ if (isTerminated(current.status)) {
550
+ throw new Error(`Vercel sandbox "${handle.id}" is ${current.status} and cannot be resumed`);
551
+ }
552
+ if (isStopping(current.status)) {
553
+ await this.waitForSettled(handle.id, isStopped, "stopped", deadline);
554
+ }
555
+ const resumed = await this.run(deadline, "resume", (signal) => this.api().then((api) => api.get({ name: handle.id, resume: true, signal })));
556
+ this.instances.set(handle.id, resumed);
557
+ await this.waitForSettled(handle.id, isRunning, "running", deadline);
558
+ entry.state = "STARTED";
559
+ handle.state = "STARTED";
560
+ return handle;
561
+ }
562
+ /**
563
+ * Delete a sandbox and verify it is gone.
564
+ *
565
+ * The registration is retained when verification fails, so a caller can retry
566
+ * cleanup. Dropping it would make a leaked sandbox unreachable through this
567
+ * runtime and therefore invisible to every subsequent audit.
568
+ */
569
+ async destroy(handle) {
570
+ const entry = this.registrations.get(handle.id);
571
+ if (!entry) {
572
+ return;
573
+ }
574
+ if (!entry.owned) {
575
+ this.forget(handle.id);
576
+ return;
577
+ }
578
+ this.requireOwnedName(handle.id);
579
+ // The delete call and the proof of absence share one budget: verification
580
+ // is part of the teardown, not a second operation with its own allowance.
581
+ const deadline = new Deadline(this.deleteTimeoutMs, "delete");
582
+ try {
583
+ const sandbox = await this.sandbox(handle.id);
584
+ await this.run(deadline, "delete", (signal) => sandbox.delete({ signal }));
585
+ }
586
+ catch (error) {
587
+ if (!isVercelNotFoundError(error)) {
588
+ throw error;
589
+ }
590
+ // Already gone. Fall through to verification, which will agree.
591
+ }
592
+ const verified = await this.waitUntilDeleted(handle.id, deadline, entry.createdAtMs);
593
+ if (!verified) {
594
+ throw new VercelDestroyVerificationError(handle.id, deadline.totalMs);
595
+ }
596
+ this.forget(handle.id);
597
+ }
598
+ // --- internals ------------------------------------------------------------
599
+ async api() {
600
+ if (this.injectedApiFactory) {
601
+ return this.injectedApiFactory();
602
+ }
603
+ return createOfficialVercelSandboxApi(this.credentials);
604
+ }
605
+ /**
606
+ * Resolve a live sandbox object, caching it per name.
607
+ *
608
+ * The cached instance holds the SDK client, and that client's fetch reads the
609
+ * ambient operation deadline at call time rather than at construction time —
610
+ * so a cached handle never carries a stale deadline into a later operation.
611
+ */
612
+ async sandbox(name) {
613
+ const cached = this.instances.get(name);
614
+ if (cached) {
615
+ return cached;
616
+ }
617
+ const resolved = await this.run(this.lookupTimeoutMs, "attach", (signal) => this.api().then((api) => api.get({ name, signal })));
618
+ this.instances.set(name, resolved);
619
+ return resolved;
620
+ }
621
+ /**
622
+ * Bound one logical operation by an absolute deadline.
623
+ *
624
+ * Two mechanisms, deliberately: the ambient signal aborts the SDK's requests
625
+ * *including its internal retries*, and the race turns whatever comes back
626
+ * into one typed error. Either alone leaves a hole — the signal alone yields
627
+ * an opaque `AbortError`, the race alone leaves the request running.
628
+ */
629
+ /**
630
+ * Bound one logical operation by an absolute deadline.
631
+ *
632
+ * Two mechanisms, deliberately: the ambient signal aborts the SDK's requests
633
+ * *including its internal retries*, and the race turns whatever comes back
634
+ * into one typed error. Either alone leaves a hole — the signal alone yields
635
+ * an opaque `AbortError`, the race alone leaves the request running.
636
+ *
637
+ * Pass a `Deadline` to share one budget across a multi-round-trip operation;
638
+ * pass a number for a single call that owns its own budget.
639
+ */
640
+ run(budget, operation, fn) {
641
+ const deadline = typeof budget === "number"
642
+ ? new Deadline(budget, operation)
643
+ : budget;
644
+ const remainingMs = deadline.require();
645
+ // The retry ceiling is an absolute cap on any *single* call, so it clamps
646
+ // what remains rather than replacing the operation's budget.
647
+ const callMs = Math.min(remainingMs, this.retryDeadlineMs);
648
+ // Report whichever cap actually fired. Saying "did not complete within
649
+ // 60000ms" after 25ms because the retry ceiling cut it short would send a
650
+ // reader hunting for a slow network instead of at the ceiling they set.
651
+ const timedOut = () => callMs < remainingMs
652
+ ? new VercelOperationTimeoutError(`${operation} (SDK retry ceiling)`, callMs)
653
+ : new VercelOperationTimeoutError(deadline.operation, deadline.totalMs);
654
+ return withRequestDeadline(callMs, async () => {
655
+ const signal = currentRequestDeadlineSignal();
656
+ let timer;
657
+ try {
658
+ return await Promise.race([
659
+ fn(signal).catch((error) => {
660
+ if (isAbortError(error)) {
661
+ throw timedOut();
662
+ }
663
+ throw error;
664
+ }),
665
+ new Promise((_resolve, reject) => {
666
+ timer = setTimeout(() => reject(timedOut()), callMs);
667
+ }),
668
+ ]);
669
+ }
670
+ finally {
671
+ // Always cleared: a pinned timer keeps the event loop alive and stops
672
+ // a short-lived process from exiting.
673
+ if (timer !== undefined) {
674
+ clearTimeout(timer);
675
+ }
676
+ }
677
+ });
678
+ }
679
+ commandParams(options) {
680
+ const env = validateEnv(options.env ?? {});
681
+ return {
682
+ // The port speaks shell scripts; the provider speaks argv. Wrapping in
683
+ // `sh -c` bridges them without interpolating anything into the script:
684
+ // cwd and env go through the provider's own fields, so no caller value is
685
+ // ever spliced into a command string.
686
+ cmd: "sh",
687
+ args: ["-c", options.command],
688
+ ...(options.cwd ? { cwd: options.cwd } : {}),
689
+ ...(hasEntries(env) ? { env: { ...env } } : {}),
690
+ };
691
+ }
692
+ async collect(command, budget) {
693
+ const [stdout, stderr] = await this.run(budget, "command output", async (signal) => [
694
+ await command.output("stdout", { signal }),
695
+ await command.output("stderr", { signal }),
696
+ ]);
697
+ return {
698
+ output: combineOutput(stdout, stderr),
699
+ ...(stdout ? { stdout } : {}),
700
+ ...(stderr ? { stderr } : {}),
701
+ exitCode: command.exitCode,
702
+ cmdId: command.cmdId,
703
+ // `truncated` is deliberately omitted: the provider does not report a cap,
704
+ // and `undefined` means "not reported", never "known complete".
705
+ };
706
+ }
707
+ register(name, next) {
708
+ const existing = this.registrations.get(name);
709
+ this.registrations.set(name, {
710
+ owned: existing?.owned === true || next.owned,
711
+ labels: hasEntries(next.labels)
712
+ ? { ...next.labels }
713
+ : existing?.labels ?? {},
714
+ state: next.state ?? existing?.state,
715
+ createdAtMs: next.createdAtMs ?? existing?.createdAtMs,
716
+ });
717
+ }
718
+ forget(name) {
719
+ this.registrations.delete(name);
720
+ this.instances.delete(name);
721
+ }
722
+ requireRegistered(handle) {
723
+ const entry = this.registrations.get(handle.id);
724
+ if (!entry) {
725
+ throw new Error(`Vercel runtime handle "${handle.id}" is not attached`);
726
+ }
727
+ return entry;
728
+ }
729
+ requireOwnedName(name) {
730
+ if (!this.isOwnedName(name)) {
731
+ throw new VercelForeignSandboxError(name, this.namePrefix);
732
+ }
733
+ }
734
+ /**
735
+ * Walk the sandbox listing one page at a time.
736
+ *
737
+ * A generator so callers can consume pages incrementally: a single-result
738
+ * lookup can stop after the first candidate on page one instead of forcing
739
+ * every page in the project to load before filtering. Every page still draws
740
+ * from one shared budget, so a listing cannot cost pages x timeout.
741
+ */
742
+ async *listPages(params, budget, operation, pageSize) {
743
+ const limit = pageSize && pageSize > 0
744
+ ? Math.ceil(pageSize)
745
+ : this.listPageSize;
746
+ const deadline = typeof budget === "number"
747
+ ? new Deadline(budget, operation)
748
+ : budget;
749
+ let cursor;
750
+ for (let page = 0; page < this.maxListPages; page += 1) {
751
+ const response = await this.run(deadline, operation, (signal) => this.api().then((api) => api.list({
752
+ ...params,
753
+ limit,
754
+ ...(cursor ? { cursor } : {}),
755
+ signal,
756
+ })));
757
+ if (!response || !Array.isArray(response.sandboxes)) {
758
+ throw new Error("Vercel sandbox list response is malformed");
759
+ }
760
+ const malformed = response.sandboxes.findIndex((item) => !isVercelSandboxListItem(item));
761
+ if (malformed !== -1) {
762
+ throw new Error(`Vercel sandbox list item ${malformed} is malformed`);
763
+ }
764
+ yield response.sandboxes;
765
+ const next = response.pagination?.next;
766
+ if (!next) {
767
+ return;
768
+ }
769
+ cursor = next;
770
+ }
771
+ throw new VercelListPageLimitError(this.maxListPages, operation);
772
+ }
773
+ async listAll(params, budget, operation, pageSize) {
774
+ const items = [];
775
+ for await (const page of this.listPages(params, budget, operation, pageSize)) {
776
+ items.push(...page);
777
+ }
778
+ return items;
779
+ }
780
+ async remoteByName(name, budget) {
781
+ // `namePrefix` narrows server-side; the exact-name match is still done here
782
+ // because a prefix query legitimately returns siblings.
783
+ const items = await this.listAll({ namePrefix: name }, budget, `lookup for sandbox "${name}"`);
784
+ return items.find((item) => item.name === name) ?? null;
785
+ }
786
+ async requireRemote(name, budget = this.lookupTimeoutMs) {
787
+ const item = await this.remoteByName(name, budget);
788
+ if (!item) {
789
+ throw new Error(`Vercel sandbox "${name}" is no longer available`);
790
+ }
791
+ return item;
792
+ }
793
+ async waitForSettled(name, settled, description, deadline) {
794
+ let lastState = "unknown";
795
+ for (;;) {
796
+ const item = await this.remoteByName(name, Math.min(this.lookupTimeoutMs, Math.max(1, deadline.remaining())));
797
+ if (!item) {
798
+ throw new Error(`Vercel sandbox "${name}" disappeared while waiting for ${description}`);
799
+ }
800
+ lastState = item.status;
801
+ if (settled(item.status)) {
802
+ return item;
803
+ }
804
+ if (isTerminated(item.status)) {
805
+ throw new Error(`Vercel sandbox "${name}" became ${item.status} while waiting for ${description}`);
806
+ }
807
+ if (deadline.expired()) {
808
+ throw new VercelLifecycleTimeoutError(name, description, lastState, deadline.totalMs);
809
+ }
810
+ await delay(this.pollIntervalMs);
811
+ }
812
+ }
813
+ /**
814
+ * Poll until the named sandbox is verifiably gone.
815
+ *
816
+ * Vercel deletion removes the row rather than flagging it, so absence is the
817
+ * proof. The `createdAtMs` guard covers the one case absence alone gets
818
+ * wrong: a *new* sandbox created under the same name would otherwise read as
819
+ * our deleted one surviving, and we would report a leak that does not exist —
820
+ * or worse, keep waiting on it and time out a clean teardown.
821
+ */
822
+ async waitUntilDeleted(name, deadline, createdAtMs) {
823
+ for (;;) {
824
+ const item = await this.remoteByName(name, Math.min(this.lookupTimeoutMs, Math.max(1, deadline.remaining())));
825
+ if (!item) {
826
+ return true;
827
+ }
828
+ if (createdAtMs !== undefined && item.createdAt !== createdAtMs) {
829
+ return true;
830
+ }
831
+ if (deadline.expired()) {
832
+ return false;
833
+ }
834
+ await delay(this.pollIntervalMs);
835
+ }
836
+ }
837
+ toHandle(item, options = {}) {
838
+ return {
839
+ id: item.name,
840
+ state: normalizeState(item.status),
841
+ homeDir: options.homeDir ?? this.defaultHomeDir,
842
+ ...(options.workdir ? { workdir: options.workdir } : {}),
843
+ createdAt: new Date(item.createdAt).toISOString(),
844
+ ...(item.updatedAt
845
+ ? { updatedAt: new Date(item.updatedAt).toISOString() }
846
+ : {}),
847
+ };
848
+ }
849
+ ownedName(requested) {
850
+ if (!requested) {
851
+ return `${this.namePrefix}-${randomUUID().replaceAll("-", "").slice(0, 16)}`;
852
+ }
853
+ if (this.isOwnedName(requested)) {
854
+ return requested;
855
+ }
856
+ // A caller-supplied name is slugged and salted with a digest of the
857
+ // original. Two different requests that slug to the same string therefore
858
+ // still get distinct sandboxes instead of silently colliding on one.
859
+ const digest = createHash("sha256").update(requested).digest("hex").slice(0, 8);
860
+ const room = MAX_NAME_LENGTH - this.namePrefix.length - digest.length - 2;
861
+ const slug = requested
862
+ .normalize("NFKC")
863
+ .toLowerCase()
864
+ .replace(/[^a-z0-9-]+/gu, "-")
865
+ .replace(/^-+|-+$/gu, "")
866
+ .slice(0, Math.max(1, room)) || "sandbox";
867
+ return `${this.namePrefix}-${slug}-${digest}`;
868
+ }
869
+ isOwnedName(name) {
870
+ return name === this.namePrefix || name.startsWith(`${this.namePrefix}-`);
871
+ }
872
+ }
873
+ /**
874
+ * Construction-time reconciliation between the SDK-free declarations and the
875
+ * surface actually implemented.
876
+ *
877
+ * The registries are exhaustive over every capability key, so adding a key
878
+ * without deciding how it is backed is a type error rather than an unnoticed
879
+ * `false`. A conservative *under*-claim is always allowed — that is how a
880
+ * capability stays false until live evidence promotes it — but a `true` claim
881
+ * without the implementation behind it throws here, at construction, rather
882
+ * than surfacing as a runtime surprise at the call site.
883
+ */
884
+ export function reconcileVercelCapabilities(runtime) {
885
+ const workflowRegistry = {
886
+ pty: false,
887
+ snapshots: false,
888
+ isolation: "strong",
889
+ persistentHandle: typeof runtime.getById === "function",
890
+ streamingLogs: false,
891
+ };
892
+ for (const key of Object.keys(workflowRegistry)) {
893
+ if (workflowRegistry[key] !== vercelWorkflowCapabilities[key]) {
894
+ throw new VercelCapabilityMismatchError(String(key));
895
+ }
896
+ }
897
+ const outerRegistry = {
898
+ warmLease: typeof runtime.findAllByLabels === "function",
899
+ lifecycle: typeof runtime.start === "function" && typeof runtime.stop === "function",
900
+ };
901
+ if (vercelSandboxCapabilities.warmLease && !outerRegistry.warmLease) {
902
+ throw new VercelCapabilityMismatchError("warmLease");
903
+ }
904
+ if (vercelSandboxCapabilities.lifecycle && !outerRegistry.lifecycle) {
905
+ throw new VercelCapabilityMismatchError("lifecycle");
906
+ }
907
+ // --- modes ---------------------------------------------------------------
908
+ //
909
+ // A boolean can only over-claim *whether* a capability exists; a mode can
910
+ // over-claim its *shape*, which is harder to catch later. An over-claimed mode
911
+ // reads as a settled fact, `isPendingEvidence()` reports false for it, and so
912
+ // nothing downstream ever revisits it. Each check below is a shape this
913
+ // adapter would be lying about.
914
+ //
915
+ // `filesystem` is intentionally absent from this table and from the declared
916
+ // modes — see VERCEL_FILESYSTEM_MODE_UNDECLARED. It is per-instance
917
+ // configuration whose across-restart behavior rides on the same unproven live
918
+ // probe as `lifecycle`, so "unknown" is the accurate answer.
919
+ // Read through the wide port type on purpose. `vercelCapabilityModes` is
920
+ // `as const`, so comparing its literals directly is a compile error today and
921
+ // would silently become dead code the moment someone edits a value. Widening
922
+ // keeps these checks live against whatever the table actually says.
923
+ const modes = vercelCapabilityModes;
924
+ if (modes.outputStreams === "combined-stream"
925
+ || modes.outputStreams === "separate-streams") {
926
+ // `runScript` awaits output("stdout")/output("stderr") to completion; there
927
+ // is no incremental channel on this port to back a streaming claim.
928
+ throw new VercelCapabilityMismatchError("modes.outputStreams");
929
+ }
930
+ if (modes.lifetime === "never-idle") {
931
+ // Every Vercel sandbox carries a wall-clock termination deadline.
932
+ throw new VercelCapabilityMismatchError("modes.lifetime");
933
+ }
934
+ if (modes.interactive !== "not-exposed") {
935
+ // openInteractive() is real in the SDK and unreachable through this port.
936
+ // Both a positive claim and a bare "unknown" would misdescribe that: the
937
+ // port's lack of a PTY operation is settled, not pending evidence.
938
+ throw new VercelCapabilityMismatchError("modes.interactive");
939
+ }
940
+ if (modes.snapshots !== "not-exposed") {
941
+ // Same for snapshot()/fork(): real in the SDK, no port operation reaches
942
+ // them. Distinct from observed.snapshotCapture, which a canary may promote.
943
+ throw new VercelCapabilityMismatchError("modes.snapshots");
944
+ }
945
+ const observedRegistry = {
946
+ cleanupVerified: typeof runtime.destroy === "function"
947
+ && typeof runtime.listOwned === "function",
948
+ fork: false,
949
+ lifecycle: outerRegistry.lifecycle,
950
+ neverIdle: false,
951
+ ptySurvival: false,
952
+ snapshotCapture: false,
953
+ streamingExec: false,
954
+ warmLease: outerRegistry.warmLease,
955
+ };
956
+ for (const key of Object.keys(observedRegistry)) {
957
+ if (vercelObservedCapabilities[key] && !observedRegistry[key]) {
958
+ throw new VercelCapabilityMismatchError(String(key));
959
+ }
960
+ }
961
+ }
962
+ // --- helpers ----------------------------------------------------------------
963
+ function validateNamePrefix(value) {
964
+ const prefix = required(value, "Vercel name prefix");
965
+ // Lowercase alphanumeric with internal hyphens: the intersection of what
966
+ // Vercel resource names accept and what survives a DNS label, since exposed
967
+ // ports become subdomains derived from the sandbox.
968
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(prefix)) {
969
+ throw new Error("Vercel name prefix must be lowercase alphanumeric, optionally hyphenated");
970
+ }
971
+ if (prefix.length > MAX_NAME_LENGTH - GENERATED_SUFFIX_LENGTH) {
972
+ throw new Error(`Vercel name prefix must leave ${GENERATED_SUFFIX_LENGTH} characters of room `
973
+ + `for the generated suffix within ${MAX_NAME_LENGTH} characters`);
974
+ }
975
+ return prefix;
976
+ }
977
+ function validateSource(source) {
978
+ if (!source) {
979
+ return { type: "default" };
980
+ }
981
+ if (source.type === "default") {
982
+ return { type: "default" };
983
+ }
984
+ if (source.type === "image") {
985
+ return { type: "image", image: required(source.image, "Vercel image") };
986
+ }
987
+ if (source.type === "runtime") {
988
+ return {
989
+ type: "runtime",
990
+ runtime: required(source.runtime, "Vercel runtime"),
991
+ };
992
+ }
993
+ throw new Error("Vercel sandbox source is invalid");
994
+ }
995
+ function validateVcpus(value) {
996
+ if (value === undefined) {
997
+ return undefined;
998
+ }
999
+ if (!Number.isInteger(value) || value < 1) {
1000
+ throw new Error("Vercel vcpus must be a positive integer");
1001
+ }
1002
+ return value;
1003
+ }
1004
+ function validatePorts(ports) {
1005
+ if (!ports) {
1006
+ return undefined;
1007
+ }
1008
+ if (ports.length > MAX_PORTS) {
1009
+ throw new Error(`Vercel sandboxes expose at most ${MAX_PORTS} ports; received ${ports.length}`);
1010
+ }
1011
+ for (const port of ports) {
1012
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
1013
+ throw new Error(`Invalid Vercel port "${port}"`);
1014
+ }
1015
+ }
1016
+ return [...ports];
1017
+ }
1018
+ function validateEnv(env) {
1019
+ for (const name of Object.keys(env)) {
1020
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) {
1021
+ throw new Error(`Invalid Vercel environment variable name "${name}"`);
1022
+ }
1023
+ }
1024
+ return env;
1025
+ }
1026
+ function validateTags(tags) {
1027
+ const keys = Object.keys(tags);
1028
+ if (keys.length > MAX_TAGS) {
1029
+ throw new VercelTagLimitError(keys.length);
1030
+ }
1031
+ for (const key of keys) {
1032
+ if (!key.trim()) {
1033
+ throw new Error("Vercel tag keys must be non-empty");
1034
+ }
1035
+ if (typeof tags[key] !== "string") {
1036
+ throw new Error(`Vercel tag "${key}" must have a string value`);
1037
+ }
1038
+ }
1039
+ return tags;
1040
+ }
1041
+ function required(value, description) {
1042
+ const normalized = value?.trim();
1043
+ if (!normalized) {
1044
+ throw new Error(`${description} is required`);
1045
+ }
1046
+ return normalized;
1047
+ }
1048
+ function optionalPositive(value, description) {
1049
+ if (value === undefined) {
1050
+ return undefined;
1051
+ }
1052
+ if (!Number.isFinite(value) || value <= 0) {
1053
+ throw new Error(`${description} must be a positive number`);
1054
+ }
1055
+ return Math.ceil(value);
1056
+ }
1057
+ function positiveDuration(value, fallback) {
1058
+ return value !== undefined && Number.isFinite(value) && value > 0
1059
+ ? Math.ceil(value)
1060
+ : fallback;
1061
+ }
1062
+ function hasEntries(value) {
1063
+ return !!value && Object.keys(value).length > 0;
1064
+ }
1065
+ function isVercelSandboxListItem(value) {
1066
+ return Boolean(value
1067
+ && typeof value === "object"
1068
+ && typeof value.name === "string"
1069
+ && value.name.trim()
1070
+ && typeof value.status === "string"
1071
+ && value.status.trim()
1072
+ && typeof value.createdAt === "number");
1073
+ }
1074
+ function matchesAllTags(actual, wanted) {
1075
+ const entries = Object.entries(wanted);
1076
+ if (entries.length === 0) {
1077
+ return true;
1078
+ }
1079
+ if (!actual) {
1080
+ return false;
1081
+ }
1082
+ return entries.every(([key, value]) => actual[key] === value);
1083
+ }
1084
+ /**
1085
+ * Map a provider status onto the port's uppercase state vocabulary.
1086
+ *
1087
+ * `snapshotting` maps to STOPPING because that is what it is from a caller's
1088
+ * perspective — the VM is on its way down and cannot take work.
1089
+ */
1090
+ function normalizeState(status) {
1091
+ switch (status.toLowerCase()) {
1092
+ case "running":
1093
+ return "STARTED";
1094
+ case "pending":
1095
+ return "STARTING";
1096
+ case "stopping":
1097
+ case "snapshotting":
1098
+ return "STOPPING";
1099
+ case "stopped":
1100
+ return "STOPPED";
1101
+ case "failed":
1102
+ case "aborted":
1103
+ return "FAILED";
1104
+ default:
1105
+ return status.toUpperCase();
1106
+ }
1107
+ }
1108
+ function normalizeRequestedState(state) {
1109
+ const normalized = state.toUpperCase();
1110
+ if (normalized === "RUNNING")
1111
+ return "STARTED";
1112
+ if (normalized === "PAUSED" || normalized === "SUSPENDED")
1113
+ return "STOPPED";
1114
+ return normalized;
1115
+ }
1116
+ function matchesState(status, states) {
1117
+ if (states === null) {
1118
+ return true;
1119
+ }
1120
+ const actual = normalizeState(status);
1121
+ return states.some((state) => normalizeRequestedState(state) === actual);
1122
+ }
1123
+ function isRunning(status) {
1124
+ return status.toLowerCase() === "running";
1125
+ }
1126
+ function isStopped(status) {
1127
+ return status.toLowerCase() === "stopped";
1128
+ }
1129
+ function isStopping(status) {
1130
+ const normalized = status.toLowerCase();
1131
+ return normalized === "stopping" || normalized === "snapshotting";
1132
+ }
1133
+ function isTerminated(status) {
1134
+ const normalized = status.toLowerCase();
1135
+ return normalized === "failed" || normalized === "aborted";
1136
+ }
1137
+ function isAbortError(error) {
1138
+ if (!error || typeof error !== "object") {
1139
+ return false;
1140
+ }
1141
+ const name = error.name;
1142
+ return name === "AbortError" || name === "TimeoutError";
1143
+ }
1144
+ function toMillis(value) {
1145
+ return value instanceof Date ? value.getTime() : undefined;
1146
+ }
1147
+ function toIso(value) {
1148
+ return value instanceof Date ? value.toISOString() : String(value);
1149
+ }
1150
+ function combineOutput(stdout, stderr) {
1151
+ if (stdout && stderr) {
1152
+ return stdout.endsWith("\n") || stderr.startsWith("\n")
1153
+ ? `${stdout}${stderr}`
1154
+ : `${stdout}\n${stderr}`;
1155
+ }
1156
+ return stdout || stderr || "";
1157
+ }
1158
+ function parentDirectory(destination) {
1159
+ const normalized = destination.trim().replace(/\/+$/gu, "");
1160
+ const index = normalized.lastIndexOf("/");
1161
+ return index > 0 ? normalized.slice(0, index) : null;
1162
+ }
1163
+ function shellSingleQuote(value) {
1164
+ return `'${value.replace(/'/gu, `'\\''`)}'`;
1165
+ }
1166
+ function delay(ms) {
1167
+ return new Promise((resolve) => setTimeout(resolve, ms));
1168
+ }
1169
+ //# sourceMappingURL=runtime.js.map