@claxedo/sandbox-manager 0.7.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +245 -42
  2. package/dist/checkpoint-manager.d.ts +29 -4
  3. package/dist/checkpoint-manager.mjs +133 -24
  4. package/dist/command.d.ts +9 -2
  5. package/dist/command.mjs +8 -8
  6. package/dist/contract.d.ts +687 -0
  7. package/dist/driver-billing.d.ts +29 -0
  8. package/dist/driver-catalog.d.ts +43 -58
  9. package/dist/driver-catalog.mjs +192 -229
  10. package/dist/drivers/boat-client.d.ts +95 -0
  11. package/dist/drivers/boat-named-snapshots.d.ts +31 -0
  12. package/dist/drivers/boat-template.d.ts +49 -0
  13. package/dist/drivers/boat-template.mjs +1196 -0
  14. package/dist/drivers/boat.d.ts +64 -0
  15. package/dist/drivers/boat.mjs +990 -0
  16. package/dist/drivers/cloudflare.d.ts +5 -8
  17. package/dist/drivers/cloudflare.mjs +364 -213
  18. package/dist/drivers/docker.d.ts +4 -3
  19. package/dist/drivers/docker.mjs +244 -165
  20. package/dist/drivers/local-brokering.d.ts +14 -0
  21. package/dist/drivers/local-brokering.mjs +1029 -0
  22. package/dist/drivers/modal.d.ts +4 -7
  23. package/dist/drivers/modal.mjs +220 -155
  24. package/dist/drivers/transient-error.d.ts +13 -0
  25. package/dist/drivers/vercel.d.ts +21 -23
  26. package/dist/drivers/vercel.mjs +279 -169
  27. package/dist/egress-policy.d.ts +134 -0
  28. package/dist/egress-warnings.d.ts +22 -0
  29. package/dist/garbage-collect.d.ts +14 -0
  30. package/dist/hosted-network-policy.d.ts +11 -4
  31. package/dist/hosted-network-policy.mjs +221 -4
  32. package/dist/image-name.d.ts +12 -2
  33. package/dist/image-name.mjs +43 -0
  34. package/dist/image.d.ts +1 -20
  35. package/dist/image.mjs +11 -66
  36. package/dist/index.d.ts +8 -533
  37. package/dist/index.mjs +1420 -315
  38. package/dist/json.d.ts +13 -0
  39. package/dist/lease-failure.d.ts +173 -0
  40. package/dist/lease-lifecycle.d.ts +65 -0
  41. package/dist/lease-retry-wait.d.ts +8 -0
  42. package/dist/lease-start.d.ts +21 -0
  43. package/dist/lease-types.d.ts +15 -5
  44. package/dist/manager.d.ts +2 -0
  45. package/dist/runtime-env.d.ts +15 -2
  46. package/dist/runtime-env.mjs +50 -1
  47. package/dist/runtime-snapshot.d.ts +49 -0
  48. package/dist/runtime-version.d.ts +9 -0
  49. package/dist/runtime-version.mjs +2 -0
  50. package/dist/service-url-exposure.d.ts +1 -3
  51. package/dist/service-url-exposure.mjs +1 -26
  52. package/dist/start-fresh.d.ts +17 -0
  53. package/dist/start-telemetry.d.ts +37 -0
  54. package/dist/stores/memory.d.ts +1 -1
  55. package/dist/stores/memory.mjs +65 -54
  56. package/package.json +67 -81
  57. package/dist/daytona-allow-list.d.ts +0 -16
  58. package/dist/daytona-allow-list.mjs +0 -11
  59. package/dist/drivers/box.d.ts +0 -52
  60. package/dist/drivers/box.mjs +0 -499
  61. package/dist/drivers/cloudflare-egress.d.ts +0 -40
  62. package/dist/drivers/cloudflare-egress.mjs +0 -113
  63. package/dist/drivers/daytona.d.ts +0 -127
  64. package/dist/drivers/daytona.mjs +0 -541
  65. package/dist/drivers/exe.d.ts +0 -26
  66. package/dist/drivers/exe.mjs +0 -469
  67. package/dist/drivers/fetch-bridge.d.ts +0 -10
  68. package/dist/drivers/fetch-bridge.mjs +0 -111
  69. package/dist/lease-policy.d.ts +0 -48
  70. package/dist/lease-policy.mjs +0 -280
@@ -0,0 +1,1196 @@
1
+ // src/drivers/boat-template.ts
2
+ import { generateKeyPairSync } from "node:crypto";
3
+
4
+ // src/constants.ts
5
+ var DEFAULT_WORKSPACE_RUNTIME_PORT = 2593;
6
+
7
+ // src/contract.ts
8
+ var SANDBOX_IMAGE_LABEL = "image";
9
+
10
+ // src/stores/memory.ts
11
+ function sandboxLease(input) {
12
+ return {
13
+ workspaceId: input.workspaceId,
14
+ homeRegion: input.homeRegion ?? "us-east",
15
+ driver: input.driver ?? "test",
16
+ epoch: input.epoch ?? 1,
17
+ routingId: input.routingId,
18
+ status: input.status ?? "ready",
19
+ retryCount: input.retryCount ?? 0,
20
+ createdAt: input.createdAt ?? Date.now(),
21
+ updatedAt: input.updatedAt ?? Date.now(),
22
+ sandboxId: input.sandboxId,
23
+ url: input.url,
24
+ hostId: input.hostId,
25
+ driverResourceId: input.driverResourceId,
26
+ nextRetryAt: input.nextRetryAt,
27
+ lastError: input.lastError,
28
+ lastHeartbeatAt: input.lastHeartbeatAt,
29
+ lastActivityAt: input.lastActivityAt,
30
+ labels: input.labels,
31
+ checkpoint: input.checkpoint,
32
+ persistence: input.persistence,
33
+ restore: input.restore,
34
+ start: input.start,
35
+ machineClass: input.machineClass
36
+ };
37
+ }
38
+
39
+ // ../claxedo-helpers/dist/string.mjs
40
+ function isString(value) {
41
+ return typeof value === "string";
42
+ }
43
+ function trimToUndefined(value) {
44
+ if (!isString(value))
45
+ return;
46
+ const trimmed = value.trim();
47
+ return trimmed.length > 0 ? trimmed : void 0;
48
+ }
49
+
50
+ // ../sandbox-contract/dist/index.mjs
51
+ var sandboxDriverCredentialFields = {
52
+ modal: [
53
+ { key: "token_id", label: "Token ID", secret: true },
54
+ { key: "token_secret", label: "Token Secret", secret: true }
55
+ ],
56
+ vercel: [
57
+ { key: "access_token", label: "Access Token", secret: true },
58
+ { key: "team_id", label: "Team ID" },
59
+ { key: "project_id", label: "Project ID" }
60
+ ],
61
+ cloudflare: [
62
+ { key: "api_token", label: "API Token", secret: true },
63
+ { key: "worker_url", label: "Worker URL" }
64
+ ],
65
+ boat: [{ key: "api_key", label: "API Key", secret: true }],
66
+ docker: [{ key: "image", label: "Image" }]
67
+ };
68
+ var sandboxDriverLabels = {
69
+ modal: "Modal",
70
+ vercel: "Vercel",
71
+ cloudflare: "Cloudflare",
72
+ boat: "Boat",
73
+ docker: "Docker"
74
+ };
75
+
76
+ // src/lease-failure.ts
77
+ var SandboxRuntimeBootError = class extends Error {
78
+ runtimeBootFailed = true;
79
+ constructor(reason) {
80
+ super(reason);
81
+ this.name = "SandboxRuntimeBootError";
82
+ }
83
+ };
84
+ var SandboxHostGoneError = class extends Error {
85
+ hostGone = true;
86
+ constructor(reason) {
87
+ super(reason);
88
+ this.name = "SandboxHostGoneError";
89
+ }
90
+ };
91
+ var SANDBOX_START_WINDOWS = ["minute", "hour", "day"];
92
+ var WORKSPACE_RUNTIME_BOOT_FAILED_LINE = "workspace_runtime_boot_failed: ";
93
+ function workspaceRuntimeBootReason(diagnostic) {
94
+ const at = diagnostic.lastIndexOf(WORKSPACE_RUNTIME_BOOT_FAILED_LINE);
95
+ if (at < 0) return void 0;
96
+ const [reason = ""] = diagnostic.slice(at + WORKSPACE_RUNTIME_BOOT_FAILED_LINE.length).split(/\n\s+at /);
97
+ return reason.trim().replace(/^Error: /, "") || void 0;
98
+ }
99
+ function runtimeBootDiagnostic(log, maxChars) {
100
+ const trimmed = log.trim();
101
+ const reason = workspaceRuntimeBootReason(trimmed);
102
+ if (reason === void 0) return trimmed.slice(-maxChars);
103
+ return `${WORKSPACE_RUNTIME_BOOT_FAILED_LINE}${reason}`.slice(0, maxChars);
104
+ }
105
+
106
+ // src/runtime-env.ts
107
+ var WORKSPACE_RUNTIME_IDENTITY_ENV_KEYS = [
108
+ "WORKSPACE_RUNTIME_WORKSPACE_ID",
109
+ "WORKSPACE_RUNTIME_HOST_ID",
110
+ "WORKSPACE_RUNTIME_RELAY_WORKSPACE_IDS"
111
+ ];
112
+ function workspaceRuntimeIdentityEnvConflicts(env) {
113
+ if (!env) return [];
114
+ return WORKSPACE_RUNTIME_IDENTITY_ENV_KEYS.filter((key) => env[key] !== void 0);
115
+ }
116
+ function assertWorkspaceRuntimeIdentityEnv(env) {
117
+ const conflicts = workspaceRuntimeIdentityEnvConflicts(env);
118
+ if (conflicts.length === 0) return;
119
+ throw new Error(
120
+ `sandbox env cannot set runtime identity ${conflicts.join(", ")}: the driver's placement owns the identity its target reports and the lease records`
121
+ );
122
+ }
123
+ var WORKSPACE_RUNTIME_PUBLIC_ENV_KEYS = /* @__PURE__ */ new Set([
124
+ ...WORKSPACE_RUNTIME_IDENTITY_ENV_KEYS,
125
+ "WORKSPACE_RUNTIME_DIRECTORY",
126
+ "WORKSPACE_RUNTIME_PORT",
127
+ "WORKSPACE_RUNTIME_HOST",
128
+ "WORKSPACE_RUNTIME_SOURCE_KIND",
129
+ "WORKSPACE_RUNTIME_GIT_REPO_URL",
130
+ "WORKSPACE_RUNTIME_GIT_BRANCH",
131
+ "WORKSPACE_RUNTIME_NATIVE_HARNESS",
132
+ "WORKSPACE_RUNTIME_RELAY_JWKS_URL",
133
+ "WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL",
134
+ "WORKSPACE_RUNTIME_SESSION_AUTHORITY_URL"
135
+ ]);
136
+ function workspaceRuntimeBootSecrets(env) {
137
+ return Object.entries(env).flatMap(([key, value]) => WORKSPACE_RUNTIME_PUBLIC_ENV_KEYS.has(key) ? [] : [value]);
138
+ }
139
+ function workspaceRuntimeBootEnv(input) {
140
+ assertWorkspaceRuntimeIdentityEnv(input.env);
141
+ const env = {
142
+ ...workspaceRuntimeTargetEnv(input),
143
+ ...workspaceRuntimeSourceEnv(input),
144
+ ...input.env
145
+ };
146
+ if (input.nativeHarness) env.WORKSPACE_RUNTIME_NATIVE_HARNESS = input.nativeHarness;
147
+ if (input.controlEnv?.relayJwksUrl) env.WORKSPACE_RUNTIME_RELAY_JWKS_URL = input.controlEnv.relayJwksUrl;
148
+ if (input.controlEnv?.relayVerifyPem) env.WORKSPACE_RUNTIME_RELAY_HOST_VERIFY_PEM = input.controlEnv.relayVerifyPem;
149
+ if (input.controlEnv?.managementJwksUrl) env.WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL = input.controlEnv.managementJwksUrl;
150
+ if (input.controlEnv?.sessionAuthorityUrl) env.WORKSPACE_RUNTIME_SESSION_AUTHORITY_URL = input.controlEnv.sessionAuthorityUrl;
151
+ return env;
152
+ }
153
+ function workspaceRuntimeTargetEnv(input) {
154
+ return {
155
+ WORKSPACE_RUNTIME_WORKSPACE_ID: input.workspaceId,
156
+ ...input.hostId ? { WORKSPACE_RUNTIME_HOST_ID: input.hostId } : {},
157
+ WORKSPACE_RUNTIME_DIRECTORY: input.directory,
158
+ WORKSPACE_RUNTIME_PORT: String(input.port),
159
+ ...input.host ? { WORKSPACE_RUNTIME_HOST: input.host } : {}
160
+ };
161
+ }
162
+ function workspaceRuntimeSourceEnv(input) {
163
+ if (!input.source || input.source.kind === "empty") return { WORKSPACE_RUNTIME_SOURCE_KIND: "empty" };
164
+ return {
165
+ WORKSPACE_RUNTIME_SOURCE_KIND: "git",
166
+ WORKSPACE_RUNTIME_GIT_REPO_URL: input.source.repoUrl,
167
+ ...input.source.branch ? { WORKSPACE_RUNTIME_GIT_BRANCH: input.source.branch } : {}
168
+ };
169
+ }
170
+
171
+ // src/command.ts
172
+ function shell(input) {
173
+ return `'${input.replace(/'/g, `'"'"'`)}'`;
174
+ }
175
+ function envFile(input) {
176
+ return Object.entries(input).map(([key, value]) => {
177
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
178
+ throw new Error(`environment variable ${JSON.stringify(key)} cannot be exported through an env file`);
179
+ }
180
+ return `export ${key}=${shell(value)}`;
181
+ }).join("\n") + "\n";
182
+ }
183
+
184
+ // src/image-name.ts
185
+ function assertSandboxImageReference(image) {
186
+ if (!image || image !== image.trim() || image.startsWith("-") || /\s/.test(image)) {
187
+ throw new Error(`sandbox image reference is not a legal image identifier: ${JSON.stringify(image)}`);
188
+ }
189
+ return image;
190
+ }
191
+
192
+ // src/driver-billing.ts
193
+ var HOUR_SECONDS = 3600;
194
+ var boatBilling = {
195
+ source: { url: "https://docs.boat.dev/pricing", readOn: "2026-10-12" },
196
+ meter: "Boat bills machine time per second while a sandbox runs, at an hourly rate per size; a stopped sandbox costs nothing.",
197
+ rates: {
198
+ small: { size: "small", vcpu: 2, memoryGb: 4, usdPerHour: 0.018 },
199
+ default: { size: "default", vcpu: 4, memoryGb: 8, usdPerHour: 0.036 },
200
+ large: { size: "large", vcpu: 8, memoryGb: 16, usdPerHour: 0.072 }
201
+ }
202
+ };
203
+ var CLOUDFLARE_USD_PER_GIB_SECOND_MEMORY = 25e-7;
204
+ var CLOUDFLARE_USD_PER_GB_SECOND_DISK = 7e-8;
205
+ var CLOUDFLARE_USD_PER_VCPU_SECOND_ACTIVE = 2e-5;
206
+ var CLOUDFLARE_STANDARD_1 = { size: "standard-1", vcpu: 0.5, memoryGb: 4, diskGb: 8 };
207
+ var cloudflareBilling = {
208
+ source: { url: "https://developers.cloudflare.com/containers/pricing/", readOn: "2026-10-12" },
209
+ meter: "Cloudflare bills a container for every 10 ms it runs: memory at $0.0000025 per GiB-second and disk at $0.00000007 per GB-second for the whole run, plus CPU at $0.00002 per vCPU-second of active use.",
210
+ rates: {
211
+ default: {
212
+ size: CLOUDFLARE_STANDARD_1.size,
213
+ vcpu: CLOUDFLARE_STANDARD_1.vcpu,
214
+ memoryGb: CLOUDFLARE_STANDARD_1.memoryGb,
215
+ usdPerHour: (CLOUDFLARE_STANDARD_1.memoryGb * CLOUDFLARE_USD_PER_GIB_SECOND_MEMORY + CLOUDFLARE_STANDARD_1.diskGb * CLOUDFLARE_USD_PER_GB_SECOND_DISK) * HOUR_SECONDS
216
+ }
217
+ },
218
+ excludes: `active CPU, billed at $${CLOUDFLARE_USD_PER_VCPU_SECOND_ACTIVE} per vCPU-second and not measured by Claxedo, and the monthly allowance a Workers Paid plan includes`
219
+ };
220
+
221
+ // src/driver-catalog.ts
222
+ var sandboxDriverCatalog = {
223
+ modal: {
224
+ id: "modal",
225
+ label: sandboxDriverLabels.modal,
226
+ credentialFields: sandboxDriverCredentialFields.modal,
227
+ runtimeNetwork: { controlPlane: "direct", relay: "configured" },
228
+ runtimeEnv: "callback",
229
+ preparedImage: true,
230
+ metadata: {
231
+ driverRunsIn: ["node"],
232
+ hostStopBehavior: "terminates-host",
233
+ hostResumeBehavior: "replacement-host",
234
+ targetAccess: "relay",
235
+ secretBrokering: "none",
236
+ // The driver does not install Modal's domain allowlist or alpha sidecar.
237
+ egressControl: "none",
238
+ persistence: {
239
+ resume: "replacement-restore",
240
+ capture: "filesystem",
241
+ clone: false,
242
+ captureSource: "preserved",
243
+ retention: "provider-managed",
244
+ restoreMount: "new-resource"
245
+ }
246
+ }
247
+ },
248
+ vercel: {
249
+ id: "vercel",
250
+ label: sandboxDriverLabels.vercel,
251
+ credentialFields: sandboxDriverCredentialFields.vercel,
252
+ runtimeNetwork: { controlPlane: "direct", relay: "configured" },
253
+ runtimeEnv: "callback",
254
+ preparedImage: false,
255
+ metadata: {
256
+ driverRunsIn: ["node"],
257
+ hostStopBehavior: "terminates-host",
258
+ hostResumeBehavior: "replacement-host",
259
+ targetAccess: "relay",
260
+ secretBrokering: "native",
261
+ // The driver translates hosts only; the SDK's subnet rules are not wired to net.cidrs.
262
+ egressControl: "hosts",
263
+ persistence: {
264
+ resume: "replacement-restore",
265
+ capture: "filesystem",
266
+ clone: false,
267
+ captureSource: "stopped",
268
+ retention: "explicit",
269
+ restoreMount: "new-resource"
270
+ }
271
+ }
272
+ },
273
+ cloudflare: {
274
+ id: "cloudflare",
275
+ label: sandboxDriverLabels.cloudflare,
276
+ credentialFields: sandboxDriverCredentialFields.cloudflare,
277
+ runtimeNetwork: { controlPlane: "direct", relay: "configured" },
278
+ runtimeEnv: "callback",
279
+ preparedImage: false,
280
+ billing: cloudflareBilling,
281
+ metadata: {
282
+ driverRunsIn: ["worker"],
283
+ hostStopBehavior: "terminates-host",
284
+ hostResumeBehavior: "same-host",
285
+ hostWatchesIdle: true,
286
+ targetAccess: "relay",
287
+ secretBrokering: "native",
288
+ // Credential handlers do not restrict unrelated destinations.
289
+ egressControl: "none",
290
+ // A container snapshot holds the whole root filesystem; the platform keeps it 30 days past its last restore and offers no deletion.
291
+ persistence: {
292
+ resume: "replacement-restore",
293
+ capture: "filesystem",
294
+ clone: false,
295
+ captureSource: "preserved",
296
+ retention: "provider-managed",
297
+ restoreMount: "new-resource"
298
+ }
299
+ }
300
+ },
301
+ boat: {
302
+ id: "boat",
303
+ label: sandboxDriverLabels.boat,
304
+ credentialFields: sandboxDriverCredentialFields.boat,
305
+ runtimeNetwork: { controlPlane: "direct", relay: "configured" },
306
+ runtimeEnv: "callback",
307
+ preparedImage: true,
308
+ billing: boatBilling,
309
+ metadata: {
310
+ driverRunsIn: ["worker", "node"],
311
+ hostStopBehavior: "suspends-host",
312
+ hostResumeBehavior: "same-host",
313
+ targetAccess: "relay",
314
+ secretBrokering: "none",
315
+ egressControl: "none",
316
+ persistence: {
317
+ resume: "same-sandbox",
318
+ capture: "none",
319
+ clone: false,
320
+ captureSource: "not-applicable",
321
+ retention: "provider-managed",
322
+ restoreMount: "same-resource"
323
+ },
324
+ machineClasses: ["small", "default", "large"]
325
+ }
326
+ },
327
+ docker: {
328
+ id: "docker",
329
+ label: sandboxDriverLabels.docker,
330
+ credentialFields: sandboxDriverCredentialFields.docker,
331
+ runtimeNetwork: { controlPlane: "docker-host", relay: "configured" },
332
+ runtimeEnv: "callback",
333
+ preparedImage: true,
334
+ metadata: {
335
+ driverRunsIn: ["local"],
336
+ hostStopBehavior: "terminates-host",
337
+ hostResumeBehavior: "same-host",
338
+ targetAccess: "loopback",
339
+ secretBrokering: "none",
340
+ egressControl: "none",
341
+ persistence: {
342
+ resume: "replacement-restore",
343
+ capture: "same-resource",
344
+ clone: false,
345
+ captureSource: "preserved",
346
+ retention: "provider-managed",
347
+ restoreMount: "same-resource"
348
+ }
349
+ }
350
+ }
351
+ };
352
+
353
+ // ../claxedo-helpers/dist/guards.mjs
354
+ function isRecord(value) {
355
+ return value !== null && typeof value === "object" && !Array.isArray(value);
356
+ }
357
+ function asRecord(value) {
358
+ return isRecord(value) ? value : void 0;
359
+ }
360
+
361
+ // src/json.ts
362
+ function num(input) {
363
+ return typeof input === "number" ? input : void 0;
364
+ }
365
+
366
+ // ../claxedo-helpers/dist/deadline.mjs
367
+ async function withDeadlineSignal(timeoutMs, work, parent) {
368
+ const deadline = new AbortController();
369
+ const timer = setTimeout(() => deadline.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
370
+ try {
371
+ return await work(parent ? AbortSignal.any([parent, deadline.signal]) : deadline.signal);
372
+ } finally {
373
+ clearTimeout(timer);
374
+ }
375
+ }
376
+
377
+ // src/drivers/boat-client.ts
378
+ var DEFAULT_BASE_URL = "https://boat.dev/api/v1";
379
+ var DEFAULT_TIMEOUT_MS = 12e4;
380
+ var DEFAULT_COMMAND_SECONDS = 30;
381
+ var COMMAND_RESPONSE_MARGIN_MS = 3e4;
382
+ var START_HOLD_MS = 6e4;
383
+ var STILL_STARTING_CODES = /* @__PURE__ */ new Set(["sandbox_starting", "boat_starting"]);
384
+ var MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
385
+ var SANDBOX_STATES = /* @__PURE__ */ new Set(["init", "provisioning", "provisioned", "cloning", "ready", "idle", "running", "archiving", "archived", "error", "cancelled"]);
386
+ var BoatApiError = class extends Error {
387
+ constructor(message, code, status, refusal = {}) {
388
+ super(message);
389
+ this.code = code;
390
+ this.status = status;
391
+ this.name = "BoatApiError";
392
+ this.providerKeyRefused = status === 401 || status === 403;
393
+ this.startLimit = refusal.startLimit;
394
+ this.sandboxState = refusal.sandboxState;
395
+ if (refusal.startLimit) this.providerRateLimited = { window: refusal.startLimit.window, limit: refusal.startLimit.limit };
396
+ }
397
+ /** Boat answers 401 or 403 to the API key itself, never to a sandbox it was asked about. */
398
+ providerKeyRefused;
399
+ /** Create, fork and resume each count as one start (Boat's own refusal, 2026-10-15). */
400
+ providerRateLimited;
401
+ startLimit;
402
+ /** The state Boat's refusal gives the sandbox it refused, e.g. `archived` on a 400 `stop_failed` (2026-10-17). */
403
+ sandboxState;
404
+ };
405
+ function startCount(value) {
406
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : void 0;
407
+ }
408
+ function startLimitOf(body) {
409
+ const details = asRecord(asRecord(body?.error)?.details);
410
+ const window = SANDBOX_START_WINDOWS.find((name) => name === details?.window);
411
+ const limit = startCount(details?.limit);
412
+ if (!window || limit === void 0) return void 0;
413
+ const starts = asRecord(details?.starts);
414
+ return {
415
+ window,
416
+ limit,
417
+ starts: Object.fromEntries(SANDBOX_START_WINDOWS.flatMap((name) => {
418
+ const entry = asRecord(starts?.[name]);
419
+ const entryLimit = startCount(entry?.limit);
420
+ const used = startCount(entry?.used);
421
+ return entryLimit === void 0 || used === void 0 ? [] : [[name, { limit: entryLimit, used }]];
422
+ }))
423
+ };
424
+ }
425
+ function invalid() {
426
+ throw new BoatApiError("Boat API returned an invalid response", "invalid_response");
427
+ }
428
+ function requiredText(value) {
429
+ return typeof value === "string" ? value : invalid();
430
+ }
431
+ function requiredFlag(value) {
432
+ return typeof value === "boolean" ? value : invalid();
433
+ }
434
+ function commandExitCode(value) {
435
+ return value === null ? null : typeof value === "number" && Number.isSafeInteger(value) ? value : invalid();
436
+ }
437
+ function isSandboxState(value) {
438
+ return typeof value === "string" && SANDBOX_STATES.has(value);
439
+ }
440
+ function sandbox(body) {
441
+ const value = asRecord(body.sandbox) ?? invalid();
442
+ const id = requiredText(value.id);
443
+ if (!/^bx_[23456789abcdefghjkmnpqrstuvwxyz]{8}$/.test(id) || !isSandboxState(value.state)) invalid();
444
+ return { id, state: value.state, hydrated: value.hydrated === true };
445
+ }
446
+ async function readBoundedResponseJson(response) {
447
+ const reader = response.body?.getReader() ?? invalid();
448
+ const decoder = new TextDecoder();
449
+ let text = "";
450
+ let size = 0;
451
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
452
+ size += next.value.byteLength;
453
+ if (size > MAX_RESPONSE_BYTES) {
454
+ await reader.cancel();
455
+ invalid();
456
+ }
457
+ text += decoder.decode(next.value, { stream: true });
458
+ }
459
+ return asRecord(JSON.parse(text + decoder.decode()));
460
+ }
461
+ function createBoatApi(options) {
462
+ const base = new URL(options.baseUrl ?? DEFAULT_BASE_URL);
463
+ if (base.protocol !== "https:" || base.username || base.password || base.search || base.hash) {
464
+ throw new Error("Boat endpoint requires HTTPS without credentials, query or fragment");
465
+ }
466
+ const baseUrl = base.href.replace(/\/+$/, "");
467
+ const fetchImpl = options.fetchImpl ?? fetch;
468
+ async function api(path, types, init) {
469
+ return withDeadlineSignal(init?.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS, async (signal) => {
470
+ let response;
471
+ try {
472
+ response = await fetchImpl(`${baseUrl}${path}`, {
473
+ method: init?.method ?? "GET",
474
+ redirect: "manual",
475
+ headers: {
476
+ Authorization: `Bearer ${options.apiKey}`,
477
+ ...init?.body === void 0 ? {} : { "Content-Type": "application/json" },
478
+ ...init?.headers
479
+ },
480
+ ...init?.body === void 0 ? {} : { body: JSON.stringify(init.body) },
481
+ signal
482
+ });
483
+ } catch {
484
+ throw new BoatApiError(`Boat ${init?.method ?? "GET"} ${path} failed before acknowledgement`, "transport_failed");
485
+ }
486
+ const body = await readBoundedResponseJson(response).catch(() => void 0);
487
+ if (!response.ok || body?.ok === false) {
488
+ const code = typeof body?.code === "string" ? body.code : `http_${response.status}`;
489
+ const startLimit = response.status === 429 && code === "rate_limited" ? startLimitOf(body) : void 0;
490
+ const sandboxState = asRecord(asRecord(asRecord(body?.error)?.details)?.sandbox)?.state;
491
+ throw new BoatApiError(`Boat ${init?.method ?? "GET"} ${path} was rejected (${code})`, code, response.status, {
492
+ startLimit,
493
+ sandboxState: isSandboxState(sandboxState) ? sandboxState : void 0
494
+ });
495
+ }
496
+ if (body?.ok !== true || typeof body.type !== "string" || !types.includes(body.type)) {
497
+ const answered = typeof body?.type === "string" ? `"${body.type}"` : "no envelope type";
498
+ throw new BoatApiError(`Boat ${init?.method ?? "GET"} ${path} answered ${answered}, expected ${types.join(" or ")}`, "invalid_response");
499
+ }
500
+ return body;
501
+ });
502
+ }
503
+ return api;
504
+ }
505
+ function createBoatClient(options) {
506
+ const api = createBoatApi(options);
507
+ const path = (id) => `/sandboxes/${encodeURIComponent(id)}`;
508
+ return {
509
+ /** `from` is a named snapshot or a share code (`bsh_…`) the sandbox starts from; omitted, Boat's base machine. */
510
+ async create(input) {
511
+ return sandbox(await api("/sandboxes", ["sandbox.created"], {
512
+ method: "POST",
513
+ body: { noEnv: true, ttlSeconds: input.ttlSeconds, ...input.type ? { type: input.type } : {}, ...input.from ? { from: input.from } : {} },
514
+ headers: { "Idempotency-Key": input.idempotencyKey }
515
+ }));
516
+ },
517
+ async get(id) {
518
+ const value = sandbox(await api(path(id), ["sandbox.info"]));
519
+ return value.id === id ? value : invalid();
520
+ },
521
+ /** `type` resumes the sandbox onto that machine size; omitted, it keeps the size it has (docs.boat.dev/machines/change-size). */
522
+ async resume(id, input) {
523
+ await api(`${path(id)}/resume`, ["sandbox.resuming"], {
524
+ method: "POST",
525
+ body: { noEnv: true, ttlSeconds: input.ttlSeconds, ...input.type ? { type: input.type } : {} }
526
+ });
527
+ },
528
+ /**
529
+ * `ttlSeconds` sets the auto-stop deadline to now plus that many seconds; `null` removes it.
530
+ * Boat's reference example answers `sandbox.info`; the API answers `sandbox.updated` (2026-10-13).
531
+ */
532
+ async update(id, input) {
533
+ const value = sandbox(await api(path(id), ["sandbox.updated", "sandbox.info"], { method: "PATCH", body: input }));
534
+ return value.id === id ? value : invalid();
535
+ },
536
+ async stop(id) {
537
+ await api(`${path(id)}/stop`, ["sandbox.stopping"], { method: "POST" });
538
+ },
539
+ async delete(id) {
540
+ await api(path(id), ["sandbox.deleting", "deletion.operation"], { method: "DELETE", headers: { "X-Ascii-Confirm-Delete": id } });
541
+ },
542
+ async writeFile(id, input) {
543
+ const body = await api(`${path(id)}/files`, ["file.written"], { method: "PUT", body: { ...input, encoding: "utf8" } });
544
+ if (body.success !== true) invalid();
545
+ },
546
+ /** `ready` once a command runs, which Boat answers the moment the sandbox is ready; `starting` when Boat's hold ended first. */
547
+ async whenReady(id) {
548
+ try {
549
+ await api(`${path(id)}/commands`, ["command.finished"], {
550
+ method: "POST",
551
+ body: { command: "true", timeoutSeconds: 1 },
552
+ timeoutMs: START_HOLD_MS + COMMAND_RESPONSE_MARGIN_MS
553
+ });
554
+ return "ready";
555
+ } catch (err) {
556
+ if (err instanceof BoatApiError && STILL_STARTING_CODES.has(err.code)) return "starting";
557
+ throw err;
558
+ }
559
+ },
560
+ async command(id, input) {
561
+ const timeoutMs = (input.timeoutSeconds ?? DEFAULT_COMMAND_SECONDS) * 1e3 + COMMAND_RESPONSE_MARGIN_MS;
562
+ const body = await api(`${path(id)}/commands`, ["command.finished"], { method: "POST", body: input, timeoutMs });
563
+ return {
564
+ success: requiredFlag(body.success),
565
+ stdout: requiredText(body.stdout),
566
+ stderr: requiredText(body.stderr),
567
+ exitCode: commandExitCode(body.exitCode),
568
+ timedOut: requiredFlag(body.timedOut)
569
+ };
570
+ }
571
+ };
572
+ }
573
+
574
+ // src/drivers/transient-error.ts
575
+ function textOf(input) {
576
+ return typeof input === "string" ? input : "";
577
+ }
578
+ function driverErrorSignals(err) {
579
+ const shaped = isRecord(err) ? err : {};
580
+ const nested = isRecord(shaped.response) ? shaped.response : {};
581
+ return {
582
+ // `response.status` is the axios shape; `statusCode` is what SDK-native
583
+ // error classes expose instead.
584
+ status: num(nested.status) ?? num(shaped.status) ?? num(shaped.statusCode),
585
+ text: `${textOf(shaped.code)} ${textOf(shaped.name)} ${textOf(shaped.message)}`.toLowerCase()
586
+ };
587
+ }
588
+ function isTransientDriverError(err, markers) {
589
+ const { status, text } = driverErrorSignals(err);
590
+ if (status !== void 0 && status >= 500) return true;
591
+ return markers.some((marker) => text.includes(marker));
592
+ }
593
+ var TRANSIENT_DRIVER_RETRY_MS = 2e3;
594
+
595
+ // src/drivers/boat.ts
596
+ var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
597
+ var DEFAULT_WORKSPACE_DIR = "/workspace";
598
+ var DEFAULT_CONTAINER_NAME = "claxedo-runtime";
599
+ var DEFAULT_PROVISION_TIMEOUT_MS = 12e4;
600
+ var DEFAULT_STATE_POLL_MS = { firstMs: 250, maxMs: 2e3 };
601
+ var DEFAULT_HEALTH_TIMEOUT_MS = 10 * 6e4;
602
+ var DEFAULT_HEALTH_INTERVAL_MS = 100;
603
+ var HEALTH_PROBE_WINDOW_SECONDS = 50;
604
+ var HEALTH_PROBE_MARGIN_SECONDS = 10;
605
+ var BOOTING_CONTAINER_STATES = /* @__PURE__ */ new Set(["running", "paused"]);
606
+ var IMAGE_PREPARATION_TIMEOUT_SECONDS = 600;
607
+ var CONTAINER_START_TIMEOUT_SECONDS = 120;
608
+ var HOST_PUBLISH_TIMEOUT_SECONDS = 120;
609
+ var BOOT_TTL_SECONDS = 30 * 60;
610
+ var DOCKER_DAEMON_WAIT_SECONDS = 120;
611
+ var RUNTIME_ENV_PATH = ".claxedo-runtime-env";
612
+ var REGISTRY_PASSWORD_PATH = ".claxedo-registry-password";
613
+ var BOOT_LOCK_PATH = "/run/lock/claxedo-boot.lock";
614
+ var BOOT_LOCK_HEADROOM_SECONDS = 60;
615
+ var CONTAINER_ENV_PATH = "/run/claxedo-runtime.env";
616
+ var PERSISTENT_ROOT = "claxedo-persistent";
617
+ var PERSISTENT_HOME_MOUNTS = [["claxedo", "/root/.claxedo"], ["workspace-runtime", "/root/.workspace-runtime"]];
618
+ var IMAGE_TREES = ["root", "usr", "etc", "var", "opt", "home", "srv"];
619
+ var IMAGE_TREE_SEED_EXCLUDES = ["root/.cache", "root/.npm/_cacache"];
620
+ var IMAGE_TREES_COMPLETE_MARKER = ".complete";
621
+ var SEED_CONTAINER_NAME = "claxedo-seed";
622
+ var STUB_IMAGE = "claxedo-runtime-stub";
623
+ var STUB_IMAGE_ARCHIVE = "stub.tar";
624
+ var IMAGE_LABEL = "claxedo.image";
625
+ var IMAGE_ENV_FILE = "image.env";
626
+ var ROOT_LINKS = ["bin", "lib", "lib64", "sbin"];
627
+ var ROOT_DIRECTORIES = ["boot", "dev", "media", "mnt", "proc", "run", "sys", "tmp", ...IMAGE_TREES];
628
+ var BOAT_SAVED_IMAGES = "/var/lib/ascii-docker-images";
629
+ var BOAT_CONTAINER_RECORD = "/etc/ascii-docker.json";
630
+ var DOCKER_NAME_IN_USE = "is already in use by container";
631
+ var CONTAINER_CREATE_ATTEMPTS = 5;
632
+ var CONTAINER_SECURITY_OPTIONS = "--security-opt seccomp=unconfined --security-opt apparmor=unconfined";
633
+ var VM_DOCKER_INIT = "/usr/libexec/docker/docker-init";
634
+ var CONTAINER_INIT_PATH = "/claxedo-init";
635
+ var CONTAINER_STOP_GRACE_SECONDS = 30;
636
+ var HOST_RELAY_NOT_STOPPED = "The sandbox could not stop the localhost relay of this port. Try again.";
637
+ var STDERR_TAIL_CHARS = 600;
638
+ var REDACTED_VALUE_MIN_LENGTH = 8;
639
+ var READY_STATES = /* @__PURE__ */ new Set(["ready", "idle", "running"]);
640
+ var PENDING_STATES = /* @__PURE__ */ new Set(["init", "provisioning", "provisioned", "cloning"]);
641
+ var TRANSIENT_MARKERS = ["transport_failed", "idempotency_in_progress"];
642
+ var GONE_CODES = /* @__PURE__ */ new Set(["not_found", "snapshot_data_expired"]);
643
+ var RESUME_UNDER_WAY = "sandbox_resuming";
644
+ var NOT_RUNNING_CODES = /* @__PURE__ */ new Set(["machine_not_running", "sandbox_not_ready"]);
645
+ var TEMPLATE_UNREADABLE_CODES = /* @__PURE__ */ new Set(["snapshot_not_found", "api_key_action_forbidden", "api_key_sandbox_forbidden"]);
646
+ function boatTemplateResetCommand() {
647
+ const bootFiles = [
648
+ `${PERSISTENT_ROOT}/workspace`,
649
+ ...PERSISTENT_HOME_MOUNTS.map(([source]) => `${PERSISTENT_ROOT}/${source}`),
650
+ RUNTIME_ENV_PATH,
651
+ `${RUNTIME_ENV_PATH}.*`,
652
+ `${REGISTRY_PASSWORD_PATH}.*`
653
+ ];
654
+ return `{ docker rm -f ${DEFAULT_CONTAINER_NAME} >/dev/null 2>&1 || true; } && sudo rm -rf ${bootFiles.join(" ")} && [ -z "$(docker ps -aq)" ] && echo '[]' | sudo tee ${BOAT_CONTAINER_RECORD} >/dev/null && sudo find ${BOAT_SAVED_IMAGES} -mindepth 1 -delete && ls -d ${PERSISTENT_ROOT}/image-*/${IMAGE_TREES_COMPLETE_MARKER}`;
655
+ }
656
+ function hostGoneOrThrow(err) {
657
+ if (err instanceof BoatApiError && GONE_CODES.has(err.code)) throw new SandboxHostGoneError(err.message);
658
+ throw err;
659
+ }
660
+ function notRunningOrThrow(err) {
661
+ if (err instanceof BoatApiError && NOT_RUNNING_CODES.has(err.code)) return;
662
+ hostGoneOrThrow(err);
663
+ }
664
+ function archivedOrThrow(err) {
665
+ if (err instanceof BoatApiError && err.code === "stop_failed" && err.sandboxState === "archived") return;
666
+ hostGoneOrThrow(err);
667
+ }
668
+ function holdingBootLock(commandTimeoutSeconds, script) {
669
+ const wait = commandTimeoutSeconds - BOOT_LOCK_HEADROOM_SECONDS;
670
+ return `exec 9>>${BOOT_LOCK_PATH} && { flock -w ${wait} 9 || { echo "another boot of this machine held ${BOOT_LOCK_PATH} for ${wait} s" >&2; exit 1; }; } && ${script}`;
671
+ }
672
+ function sleep(ms) {
673
+ return new Promise((resolve) => setTimeout(resolve, ms));
674
+ }
675
+ function scrubbed(text, secrets) {
676
+ return secrets.filter((secret) => secret.length >= REDACTED_VALUE_MIN_LENGTH).reduce((output, secret) => output.split(secret).join("[redacted]"), text);
677
+ }
678
+ function scrubbedTail(text, secrets) {
679
+ return scrubbed(text, secrets).trim().slice(-STDERR_TAIL_CHARS);
680
+ }
681
+ function imageTreesDirectory(image) {
682
+ return `image-${image.replace(/[^A-Za-z0-9._-]/g, "-")}`;
683
+ }
684
+ function containerHostname(workspaceId) {
685
+ return workspaceId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || "claxedo";
686
+ }
687
+ var BoatDriverError = class extends Error {
688
+ constructor(message) {
689
+ super(message);
690
+ this.name = "BoatDriverError";
691
+ }
692
+ };
693
+ function createBoatSandboxDriver(options) {
694
+ const client = createBoatClient({
695
+ apiKey: options.apiKey,
696
+ ...options.baseUrl ? { baseUrl: options.baseUrl } : {},
697
+ ...options.operationTimeoutMs ? { timeoutMs: options.operationTimeoutMs } : {},
698
+ ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
699
+ });
700
+ const runtimeCommand = options.runtimeCommand ?? DEFAULT_RUNTIME_COMMAND;
701
+ const workspaceDir = options.workspaceDir ?? DEFAULT_WORKSPACE_DIR;
702
+ const containerName = options.containerName ?? DEFAULT_CONTAINER_NAME;
703
+ const provisionTimeoutMs = options.provisionTimeoutMs ?? DEFAULT_PROVISION_TIMEOUT_MS;
704
+ const statePollMs = options.statePollMs ?? DEFAULT_STATE_POLL_MS;
705
+ const healthTimeoutMs = options.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS;
706
+ const healthIntervalMs = options.healthIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
707
+ function runtimePort(input) {
708
+ return input.workspaceRuntimePort ?? options.runtimePort ?? DEFAULT_WORKSPACE_RUNTIME_PORT;
709
+ }
710
+ function workspaceDirectory(input) {
711
+ return input.workspaceRoot ?? workspaceDir;
712
+ }
713
+ function resolveImage(input) {
714
+ return assertSandboxImageReference(
715
+ input.bootSource?.kind === "image" ? input.bootSource.image : input.snapshot ?? options.image
716
+ );
717
+ }
718
+ function bootEnv(input, hostId) {
719
+ return workspaceRuntimeBootEnv({
720
+ workspaceId: input.workspaceId,
721
+ hostId,
722
+ directory: workspaceDirectory(input),
723
+ port: runtimePort(input),
724
+ host: "0.0.0.0",
725
+ source: input.source,
726
+ env: input.env,
727
+ nativeHarness: options.nativeHarness,
728
+ controlEnv: options.controlEnv
729
+ });
730
+ }
731
+ async function execOrThrow(sandboxId, command, label, input = {}) {
732
+ const send = () => client.command(sandboxId, { command, ...input.timeoutSeconds ? { timeoutSeconds: input.timeoutSeconds } : {} });
733
+ let result = await send();
734
+ if (input.retryOnceOn && result.exitCode !== 0 && result.stderr.includes(input.retryOnceOn)) result = await send();
735
+ if (result.timedOut || result.exitCode !== 0) {
736
+ const outcome = result.timedOut ? "timed out" : `failed (exit ${result.exitCode ?? "signal"})`;
737
+ const stderr = scrubbedTail(result.stderr, input.secrets ?? []);
738
+ throw new BoatDriverError(`Boat ${sandboxId} ${label} ${outcome}${stderr ? `: ${stderr}` : ""}`);
739
+ }
740
+ return result;
741
+ }
742
+ async function waitUntilReady(sandboxId) {
743
+ const until = Date.now() + provisionTimeoutMs;
744
+ for (; ; ) {
745
+ const answer = await client.whenReady(sandboxId).catch(async (err) => {
746
+ if (!(err instanceof BoatApiError && NOT_RUNNING_CODES.has(err.code))) hostGoneOrThrow(err);
747
+ const { state } = await client.get(sandboxId).catch(hostGoneOrThrow);
748
+ if (!READY_STATES.has(state) && !PENDING_STATES.has(state)) throw new BoatDriverError(`Boat ${sandboxId} entered non-ready state: ${state}`);
749
+ await sleep(statePollMs.maxMs);
750
+ return "starting";
751
+ });
752
+ if (answer === "ready") return true;
753
+ if (Date.now() >= until) return false;
754
+ }
755
+ }
756
+ async function stateAfterArchiving(sandboxId) {
757
+ const until = Date.now() + provisionTimeoutMs;
758
+ let wait = statePollMs.firstMs;
759
+ for (; ; ) {
760
+ const { state } = await client.get(sandboxId).catch(hostGoneOrThrow);
761
+ if (state !== "archiving") return state;
762
+ if (Date.now() >= until) return void 0;
763
+ await sleep(wait);
764
+ wait = Math.min(wait * 2, statePollMs.maxMs);
765
+ }
766
+ }
767
+ function containerScripts(input, staged) {
768
+ const port = runtimePort(input);
769
+ const directory = workspaceDirectory(input);
770
+ const reference = resolveImage(input);
771
+ const image = shell(reference);
772
+ const trees = `"$(pwd)/${PERSISTENT_ROOT}/${imageTreesDirectory(reference)}"`;
773
+ const labelledWithImage = (object, name) => `[ "$(docker ${object} inspect --format '{{index .Config.Labels "${IMAGE_LABEL}"}}' ${name} 2>/dev/null)" = ${image} ]`;
774
+ const bootScript = [
775
+ `. ${CONTAINER_ENV_PATH}`,
776
+ `mkdir -p ${shell(directory)}`,
777
+ `cd ${shell(directory)}`,
778
+ `exec ${runtimeCommand}`
779
+ ].join(" && ");
780
+ const mounts = [["workspace", directory], ...PERSISTENT_HOME_MOUNTS];
781
+ const run = `docker run -d --name ${containerName} --hostname ${shell(containerHostname(input.workspaceId))} -p ${port}:${port} -v ${VM_DOCKER_INIT}:${CONTAINER_INIT_PATH}:ro -v "$(pwd)/${RUNTIME_ENV_PATH}:${CONTAINER_ENV_PATH}:ro" --env-file ${trees}/${IMAGE_ENV_FILE} ` + IMAGE_TREES.map((tree) => `-v ${trees}/${tree}:/${tree} `).join("") + mounts.map(([source, target]) => `-v "$(pwd)/${PERSISTENT_ROOT}/${source}":${shell(target)} `).join("") + `${CONTAINER_SECURITY_OPTIONS} --entrypoint ${CONTAINER_INIT_PATH} ${STUB_IMAGE} -- sh -lc ${shell(bootScript)}`;
782
+ const seedTrees = `sudo rm -rf ${trees} && sudo mkdir -p ${trees} && { docker rm -f ${SEED_CONTAINER_NAME} >/dev/null 2>&1 || true; } && docker create --name ${SEED_CONTAINER_NAME} ${image} true >/dev/null && for tree in ${IMAGE_TREES.join(" ")}; do docker cp ${SEED_CONTAINER_NAME}:/$tree - | sudo tar -xf - -C ${trees} ${IMAGE_TREE_SEED_EXCLUDES.map((exclude) => `--exclude=${exclude}`).join(" ")} && [ -d ${trees}/$tree ] || exit 1; done && docker rm ${SEED_CONTAINER_NAME} >/dev/null && sudo touch ${trees}/${IMAGE_TREES_COMPLETE_MARKER}`;
783
+ const seedStub = `stub=$(mktemp -d) && docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' ${image} > "$stub/${IMAGE_ENV_FILE}" && workdir=$(docker image inspect --format '{{or .Config.WorkingDir "/"}}' ${image}) && mkdir "$stub/root" ${ROOT_DIRECTORIES.map((directory2) => `"$stub/root/${directory2}"`).join(" ")} && ${ROOT_LINKS.map((link) => `ln -s usr/${link} "$stub/root/${link}"`).join(" && ")} && chmod 1777 "$stub/root/tmp" && tar --owner=0 --group=0 --numeric-owner -cf "$stub/skeleton.tar" -C "$stub/root" . && docker import --change "WORKDIR $workdir" --change ${shell(`LABEL ${IMAGE_LABEL}=${reference}`)} "$stub/skeleton.tar" ${STUB_IMAGE} >/dev/null && docker save -o "$stub/${STUB_IMAGE_ARCHIVE}" ${STUB_IMAGE} && sudo mv "$stub/${IMAGE_ENV_FILE}" "$stub/${STUB_IMAGE_ARCHIVE}" ${trees}/ && rm -rf "$stub"`;
784
+ const removeImage = `if docker image inspect ${image} >/dev/null 2>&1; then leftovers=$(docker ps -aq --filter ancestor=${image}) && { [ -z "$leftovers" ] || docker rm -f $leftovers >/dev/null; } && id=$(docker image inspect --format '{{.Id}}' ${image}) && docker rmi ${image} >/dev/null && sudo rm -f ${BOAT_SAVED_IMAGES}/"\${id#sha256:}".tar; fi`;
785
+ const seeded = [IMAGE_TREES_COMPLETE_MARKER, STUB_IMAGE_ARCHIVE, IMAGE_ENV_FILE].map((file) => `[ -f ${trees}/${file} ]`).join(" && ");
786
+ const prepare = [
787
+ `chmod 600 ${staged.env}`,
788
+ `find . -maxdepth 1 \\( -name '${RUNTIME_ENV_PATH}.*' -o -name '${REGISTRY_PASSWORD_PATH}.*' \\) -mmin +${BOOT_TTL_SECONDS / 60} -exec rm -f {} +`,
789
+ `timeout ${DOCKER_DAEMON_WAIT_SECONDS} sh -c 'until docker info >/dev/null 2>&1; do sleep 1; done'`,
790
+ `[ -x ${VM_DOCKER_INIT} ]`,
791
+ ...options.registryAuth && staged.password ? [`{ chmod 600 ${staged.password}; docker login ${shell(options.registryAuth.server)} --username ${shell(options.registryAuth.username)} --password-stdin < ${staged.password}; rc=$?; rm -f ${staged.password}; (exit $rc); }`] : [],
792
+ // Trees an earlier start marked complete hold what a person or a harness
793
+ // changed since, so a seed missing only its stub or env builds those alone.
794
+ `if ! { ${seeded}; }; then { docker image inspect ${image} >/dev/null 2>&1 || docker pull ${image} >/dev/null; } && { [ -f ${trees}/${IMAGE_TREES_COMPLETE_MARKER} ] || { ${seedTrees}; }; } && ${seedStub}; fi`,
795
+ `{ ${labelledWithImage("image", STUB_IMAGE)} || docker load -i ${trees}/${STUB_IMAGE_ARCHIVE} >/dev/null; }`,
796
+ `sudo find "$(pwd)/${PERSISTENT_ROOT}" -mindepth 1 -maxdepth 1 -name 'image-*' ! -path ${trees} -exec rm -rf {} +`
797
+ ];
798
+ const start = `mv -f ${staged.env} ${RUNTIME_ENV_PATH} && up=; for attempt in $(seq ${CONTAINER_CREATE_ATTEMPTS}); do [ "$attempt" = 1 ] || sleep 1; if ${labelledWithImage("container", containerName)}; then failure=$(docker start ${containerName} 2>&1 >/dev/null) && { up=1; break; }; else docker rm -f ${containerName} >/dev/null 2>&1; failure=$(${run} 2>&1 >/dev/null) && { up=1; break; }; fi; case "$failure" in *${shell(DOCKER_NAME_IN_USE)}*) ;; *) break ;; esac; done; [ -n "$up" ] || { echo "$failure" >&2; false; } && ${removeImage}`;
799
+ return {
800
+ prepare: holdingBootLock(IMAGE_PREPARATION_TIMEOUT_SECONDS, prepare.join(" && ")),
801
+ start: holdingBootLock(CONTAINER_START_TIMEOUT_SECONDS, start)
802
+ };
803
+ }
804
+ function healthProbe(port, windowSeconds) {
805
+ const booting = [...BOOTING_CONTAINER_STATES].join("|");
806
+ return `end=$(($(date +%s) + ${windowSeconds})); while :; do code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${port}/global/health); [ "$code" = 200 ] && break; state=$(docker inspect --format '{{.State.Status}}' ${shell(containerName)} 2>/dev/null || echo missing); case "$state" in ${booting}) ;; *) break ;; esac; [ "$(date +%s)" -lt "$end" ] || break; sleep ${healthIntervalMs / 1e3}; done; echo "$code"; echo "$state"`;
807
+ }
808
+ async function waitForHealth(sandboxId, input, secrets) {
809
+ const port = runtimePort(input);
810
+ const until = Date.now() + healthTimeoutMs;
811
+ let last = "workspace runtime not ready";
812
+ while (Date.now() < until) {
813
+ const windowSeconds = Math.max(1, Math.min(HEALTH_PROBE_WINDOW_SECONDS, Math.ceil((until - Date.now()) / 1e3)));
814
+ const probe = await client.command(sandboxId, {
815
+ command: healthProbe(port, windowSeconds),
816
+ timeoutSeconds: windowSeconds + HEALTH_PROBE_MARGIN_SECONDS
817
+ });
818
+ const [code, state] = probe.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
819
+ if (code === "200") return;
820
+ last = `health probe returned ${code || "no response"}`;
821
+ if (state !== void 0 && !BOOTING_CONTAINER_STATES.has(state)) {
822
+ last = `the runtime container is ${state}`;
823
+ break;
824
+ }
825
+ }
826
+ let diagnostic;
827
+ try {
828
+ const result = await client.command(sandboxId, {
829
+ command: `docker inspect --format 'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' ${shell(containerName)}; docker logs --tail 20 ${shell(containerName)} 2>&1`,
830
+ timeoutSeconds: 10
831
+ });
832
+ diagnostic = runtimeBootDiagnostic(scrubbed(`${result.stdout}
833
+ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
834
+ } catch {
835
+ diagnostic = "container diagnostics unavailable";
836
+ }
837
+ throw new SandboxRuntimeBootError(`Boat ${sandboxId} runtime did not become healthy: ${last}; ${diagnostic}`);
838
+ }
839
+ async function startContainer(sandboxId, input, hostId) {
840
+ const env = { ...bootEnv(input, hostId), ...await options.env?.(input, { id: hostId }) };
841
+ const boot2 = crypto.randomUUID();
842
+ const staged = {
843
+ env: `${RUNTIME_ENV_PATH}.${boot2}`,
844
+ ...options.registryAuth ? { password: `${REGISTRY_PASSWORD_PATH}.${boot2}` } : {}
845
+ };
846
+ const scripts = containerScripts(input, staged);
847
+ await client.writeFile(sandboxId, { path: staged.env, content: envFile(env) });
848
+ if (options.registryAuth && staged.password) {
849
+ await client.writeFile(sandboxId, { path: staged.password, content: options.registryAuth.password });
850
+ }
851
+ const secrets = [...workspaceRuntimeBootSecrets(env), ...options.registryAuth ? [options.registryAuth.password] : []];
852
+ let failure;
853
+ try {
854
+ await execOrThrow(sandboxId, scripts.prepare, "runtime image preparation", { timeoutSeconds: IMAGE_PREPARATION_TIMEOUT_SECONDS, secrets });
855
+ try {
856
+ await execOrThrow(sandboxId, scripts.start, "container start", { timeoutSeconds: CONTAINER_START_TIMEOUT_SECONDS, secrets });
857
+ } catch (error) {
858
+ throw error instanceof BoatDriverError ? new SandboxRuntimeBootError(error.message) : error;
859
+ }
860
+ } catch (error) {
861
+ failure = error;
862
+ }
863
+ try {
864
+ if (options.registryAuth || failure !== void 0) {
865
+ await execOrThrow(sandboxId, `rm -f ${[staged.env, staged.password].filter(Boolean).join(" ")}`, "staged file cleanup", { secrets });
866
+ }
867
+ } catch (cleanupFailure) {
868
+ if (failure === void 0) throw cleanupFailure;
869
+ if (failure instanceof Error) failure.message = `${failure.message}; ${cleanupFailure instanceof Error ? cleanupFailure.message : String(cleanupFailure)}`;
870
+ }
871
+ if (failure !== void 0) throw failure;
872
+ return secrets;
873
+ }
874
+ async function boot(sandboxId, input, hostId) {
875
+ await input.onMachineReady?.();
876
+ const port = runtimePort(input);
877
+ const secrets = await startContainer(sandboxId, input, hostId);
878
+ await input.onImageReady?.();
879
+ await execOrThrow(sandboxId, holdingBootLock(HOST_PUBLISH_TIMEOUT_SECONDS, `host ${port} --public`), "host publish", {
880
+ timeoutSeconds: HOST_PUBLISH_TIMEOUT_SECONDS,
881
+ retryOnceOn: HOST_RELAY_NOT_STOPPED
882
+ });
883
+ await waitForHealth(sandboxId, input, secrets);
884
+ await client.update(sandboxId, { ttlSeconds: null });
885
+ const urlResult = await execOrThrow(sandboxId, `host url ${port} --public`, "host url");
886
+ const url = trimToUndefined(urlResult.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).pop());
887
+ if (!url?.startsWith("https://") || url.includes("?")) {
888
+ throw new BoatDriverError(`Boat ${sandboxId} did not return an ungated HTTPS URL for port ${port}`);
889
+ }
890
+ return {
891
+ workspaceId: input.workspaceId,
892
+ sandboxId,
893
+ url,
894
+ hostId,
895
+ driverResourceId: sandboxId,
896
+ driver: { id: "boat", resourceId: sandboxId },
897
+ labels: { ...input.labels, ...resolveImage(input) === options.image ? { [SANDBOX_IMAGE_LABEL]: options.image } : {} }
898
+ };
899
+ }
900
+ function assertNetwork(input) {
901
+ if (input.net && input.net.mode !== "allow-all") {
902
+ throw new BoatDriverError("Boat SandboxDriver cannot enforce host-based network policy");
903
+ }
904
+ }
905
+ async function createSandbox(input) {
906
+ const create = (from) => client.create({
907
+ idempotencyKey: `claxedo:${input.workspaceId}:${input.epoch}${from ? ":template" : ""}`,
908
+ ttlSeconds: BOOT_TTL_SECONDS,
909
+ ...input.machineClass ? { type: input.machineClass } : {},
910
+ ...from ? { from } : {}
911
+ });
912
+ const template = options.runtimeTemplate;
913
+ if (!template || template.image !== resolveImage(input)) return create();
914
+ try {
915
+ return await create(template.shareCode);
916
+ } catch (err) {
917
+ if (!(err instanceof BoatApiError && TEMPLATE_UNREADABLE_CODES.has(err.code))) throw err;
918
+ console.warn(`Boat runtime template for ${template.image} is unreadable with this key (${err.code}); the sandbox pulls and seeds the image instead`);
919
+ return create();
920
+ }
921
+ }
922
+ async function ensureHost(input) {
923
+ assertNetwork(input);
924
+ const hostId = input.hostId ?? `boat-${input.workspaceId}`;
925
+ const created = await createSandbox(input).catch((err) => {
926
+ if (isTransientDriverError(err, TRANSIENT_MARKERS)) return void 0;
927
+ throw err;
928
+ });
929
+ if (!created) return { provisioning: true, retryAfterMs: TRANSIENT_DRIVER_RETRY_MS };
930
+ const resource = { sandboxId: created.id, hostId, driverResourceId: created.id, labels: input.labels };
931
+ const failures = [];
932
+ try {
933
+ await input.onResource?.(resource);
934
+ } catch (handoffError) {
935
+ failures.push(handoffError);
936
+ try {
937
+ await destroy({ ...resource, workspaceId: input.workspaceId });
938
+ } catch (deleteError) {
939
+ failures.push(deleteError);
940
+ }
941
+ }
942
+ if (failures.length === 1) throw failures[0];
943
+ if (failures.length > 1) throw new AggregateError(failures, `Boat ${created.id} resource handoff and deletion failed`);
944
+ const ready = await waitUntilReady(created.id);
945
+ if (!ready) return { provisioning: true, retryAfterMs: 0 };
946
+ return boot(created.id, input, hostId);
947
+ }
948
+ async function resumeHost(input) {
949
+ const sandboxId = input.lease.sandboxId;
950
+ if (!sandboxId) throw new BoatDriverError("Cannot resume Boat without its sandbox id");
951
+ assertNetwork(input.ensure);
952
+ const hostId = input.ensure.hostId ?? input.lease.hostId ?? `boat-${input.ensure.workspaceId}`;
953
+ const state = await stateAfterArchiving(sandboxId);
954
+ if (state === void 0) return { provisioning: true, retryAfterMs: 0 };
955
+ if (state === "archived") {
956
+ await client.resume(sandboxId, {
957
+ ttlSeconds: BOOT_TTL_SECONDS,
958
+ ...input.ensure.machineClass ? { type: input.ensure.machineClass } : {}
959
+ }).catch((err) => {
960
+ if (err instanceof BoatApiError && err.code === RESUME_UNDER_WAY) return;
961
+ hostGoneOrThrow(err);
962
+ });
963
+ }
964
+ const ready = await waitUntilReady(sandboxId);
965
+ if (!ready) return { provisioning: true, retryAfterMs: 0 };
966
+ return boot(sandboxId, input.ensure, hostId);
967
+ }
968
+ async function stop(target) {
969
+ await client.command(target.sandboxId, {
970
+ command: `docker stop -t ${CONTAINER_STOP_GRACE_SECONDS} ${containerName} >/dev/null 2>&1; true`,
971
+ timeoutSeconds: CONTAINER_STOP_GRACE_SECONDS + 30
972
+ }).then(() => void 0, notRunningOrThrow);
973
+ await client.stop(target.sandboxId).catch(archivedOrThrow);
974
+ }
975
+ async function destroy(target) {
976
+ await client.delete(target.sandboxId);
977
+ }
978
+ async function inspect(target) {
979
+ try {
980
+ const { state } = await client.get(target.sandboxId);
981
+ return READY_STATES.has(state) || PENDING_STATES.has(state) ? "serving" : "stopped";
982
+ } catch (err) {
983
+ if (err instanceof BoatApiError && GONE_CODES.has(err.code)) return "gone";
984
+ throw err;
985
+ }
986
+ }
987
+ return {
988
+ id: "boat",
989
+ metadata: sandboxDriverCatalog.boat.metadata,
990
+ image: options.image,
991
+ ensureHost,
992
+ resumeHost,
993
+ async touch() {
994
+ },
995
+ suspend: stop,
996
+ stop,
997
+ destroy,
998
+ inspect
999
+ };
1000
+ }
1001
+
1002
+ // src/drivers/boat-named-snapshots.ts
1003
+ var STATUSES = ["saving", "ready", "failed"];
1004
+ function namedSnapshot(value) {
1005
+ const snapshot = asRecord(value) ?? invalid();
1006
+ const status = STATUSES.find((known) => known === snapshot.status) ?? invalid();
1007
+ return { name: requiredText(snapshot.name), status, createdAt: requiredText(snapshot.createdAt) };
1008
+ }
1009
+ function share(value) {
1010
+ const entry = asRecord(value) ?? invalid();
1011
+ const status = requiredText(entry.status);
1012
+ if (status !== "active" && status !== "revoked") invalid();
1013
+ return { id: requiredText(entry.id), status, createdAt: requiredText(entry.createdAt) };
1014
+ }
1015
+ function list(value, item) {
1016
+ return Array.isArray(value) ? value.map(item) : invalid();
1017
+ }
1018
+ function createBoatNamedSnapshotClient(options) {
1019
+ const api = createBoatApi(options);
1020
+ const path = (name) => `/named-snapshots/${encodeURIComponent(name)}`;
1021
+ return {
1022
+ /** A stopped sandbox is saved from its last snapshot; the save settles in the background. */
1023
+ async save(input) {
1024
+ return namedSnapshot((await api("/named-snapshots", ["snapshot.named.saving"], { method: "POST", body: input })).snapshot);
1025
+ },
1026
+ async get(name) {
1027
+ try {
1028
+ return namedSnapshot((await api(path(name), ["snapshot.named.info"])).snapshot);
1029
+ } catch (err) {
1030
+ if (err instanceof BoatApiError && err.status === 404) return void 0;
1031
+ throw err;
1032
+ }
1033
+ },
1034
+ /** Newest first. */
1035
+ async list() {
1036
+ return list((await api("/named-snapshots", ["snapshot.named.list"])).snapshots, namedSnapshot);
1037
+ },
1038
+ async delete(name) {
1039
+ await api(path(name), ["snapshot.named.deleted"], { method: "DELETE" });
1040
+ },
1041
+ /** Boat shows the code only in this answer and keeps a hash of it. */
1042
+ async share(name) {
1043
+ const body = await api(`${path(name)}/shares`, ["snapshot.share.created"], { method: "POST" });
1044
+ return { shareCode: requiredText(body.shareCode), share: share(body.share) };
1045
+ },
1046
+ async shares(name) {
1047
+ return list((await api(`${path(name)}/shares`, ["snapshot.share.list"])).shares, share);
1048
+ },
1049
+ async revokeShare(name, shareId) {
1050
+ await api(`${path(name)}/shares/${encodeURIComponent(shareId)}`, ["snapshot.share.revoked"], { method: "DELETE" });
1051
+ }
1052
+ };
1053
+ }
1054
+
1055
+ // src/drivers/boat-template.ts
1056
+ var DEFAULT_PREFIX = "claxedo-rt-";
1057
+ var DEFAULT_KEEP = 3;
1058
+ var NAME_HASH_CHARS = 12;
1059
+ var DEFAULT_POLL_INTERVAL_MS = 2e3;
1060
+ var DEFAULT_HYDRATION_TIMEOUT_MS = 10 * 6e4;
1061
+ var DEFAULT_STOP_TIMEOUT_MS = 10 * 6e4;
1062
+ var DEFAULT_SAVE_TIMEOUT_MS = 10 * 6e4;
1063
+ function sleep2(ms) {
1064
+ return new Promise((resolve) => setTimeout(resolve, ms));
1065
+ }
1066
+ async function boatRuntimeTemplateName(image, prefix = DEFAULT_PREFIX) {
1067
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(image)));
1068
+ const name = `${prefix}${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, NAME_HASH_CHARS)}`;
1069
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) throw new Error(`Boat refuses the named snapshot name ${name}`);
1070
+ return name;
1071
+ }
1072
+ function clients(options) {
1073
+ const connection = {
1074
+ apiKey: options.apiKey,
1075
+ ...options.baseUrl ? { baseUrl: options.baseUrl } : {},
1076
+ ...options.timeoutMs ? { timeoutMs: options.timeoutMs } : {},
1077
+ ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
1078
+ };
1079
+ return { connection, sandboxes: createBoatClient(connection), snapshots: createBoatNamedSnapshotClient(connection) };
1080
+ }
1081
+ async function pollBoat(label, timeoutMs, intervalMs, read) {
1082
+ const deadline = Date.now() + timeoutMs;
1083
+ for (; ; ) {
1084
+ const value = await read();
1085
+ if (value !== void 0) return value;
1086
+ if (Date.now() >= deadline) throw new Error(`${label} did not happen within ${Math.round(timeoutMs / 1e3)} s`);
1087
+ await sleep2(intervalMs);
1088
+ }
1089
+ }
1090
+ async function ensureBoatRuntimeTemplate(options) {
1091
+ const { snapshots } = clients(options);
1092
+ const name = await boatRuntimeTemplateName(options.image, options.prefix);
1093
+ const interval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1094
+ const existing = await pollBoat(`Boat template ${name} settling`, options.saveTimeoutMs ?? DEFAULT_SAVE_TIMEOUT_MS, interval, async () => {
1095
+ const snapshot = await snapshots.get(name);
1096
+ return snapshot?.status === "saving" ? void 0 : snapshot ?? null;
1097
+ });
1098
+ if (existing?.status === "ready") {
1099
+ options.log?.(`template ${name} for ${options.image} exists`);
1100
+ return { name, built: false };
1101
+ }
1102
+ await buildTemplate(options, name);
1103
+ return { name, built: true };
1104
+ }
1105
+ async function buildTemplate(options, name) {
1106
+ const { connection, sandboxes, snapshots } = clients(options);
1107
+ const interval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1108
+ const log = options.log ?? (() => {
1109
+ });
1110
+ const started = Date.now();
1111
+ const elapsed = () => `${((Date.now() - started) / 1e3).toFixed(1)} s`;
1112
+ const driver = createBoatSandboxDriver({
1113
+ ...connection,
1114
+ image: options.image,
1115
+ controlEnv: { relayVerifyPem: generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "pem" }).toString() },
1116
+ ...options.driver
1117
+ });
1118
+ const workspaceId = `${name}-${started.toString(36)}`;
1119
+ let sandboxId;
1120
+ const ensure = (epoch) => ({
1121
+ workspaceId,
1122
+ homeRegion: "auto",
1123
+ epoch,
1124
+ labels: {},
1125
+ source: { kind: "empty" },
1126
+ onResource: async (resource) => {
1127
+ sandboxId = resource.sandboxId;
1128
+ }
1129
+ });
1130
+ const boot = async (epoch) => {
1131
+ for (; ; ) {
1132
+ const lease = sandboxLease({ workspaceId, homeRegion: "auto", driver: "boat", epoch, sandboxId });
1133
+ const result = sandboxId ? await driver.resumeHost({ lease, ensure: ensure(epoch) }) : await driver.ensureHost(ensure(epoch));
1134
+ if (!("provisioning" in result)) return result;
1135
+ await sleep2(result.retryAfterMs);
1136
+ }
1137
+ };
1138
+ const archived = (id) => pollBoat(`Boat ${id} stop`, options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS, interval, async () => (await sandboxes.get(id)).state === "archived" ? true : void 0);
1139
+ try {
1140
+ log(`building template ${name} for ${options.image}`);
1141
+ const target = await boot(1);
1142
+ log(`builder ${target.sandboxId} seeded and healthy at ${elapsed()}`);
1143
+ await driver.stop(target);
1144
+ await archived(target.sandboxId);
1145
+ log(`builder stopped at ${elapsed()}`);
1146
+ await boot(2);
1147
+ log(`builder woke at ${elapsed()}`);
1148
+ await pollBoat(`Boat ${target.sandboxId} hydration`, options.hydrationTimeoutMs ?? DEFAULT_HYDRATION_TIMEOUT_MS, interval, async () => (await sandboxes.get(target.sandboxId)).hydrated ? true : void 0);
1149
+ log(`builder hydrated at ${elapsed()}`);
1150
+ const reset = await sandboxes.command(target.sandboxId, { command: boatTemplateResetCommand(), timeoutSeconds: 120 });
1151
+ if (reset.exitCode !== 0) throw new Error(`Boat ${target.sandboxId} template reset failed (exit ${reset.exitCode ?? "signal"})`);
1152
+ await sandboxes.stop(target.sandboxId);
1153
+ await archived(target.sandboxId);
1154
+ log(`builder reset and stopped at ${elapsed()}`);
1155
+ await snapshots.save({ sandboxId: target.sandboxId, name });
1156
+ const saved = await pollBoat(`Boat template ${name} save`, options.saveTimeoutMs ?? DEFAULT_SAVE_TIMEOUT_MS, interval, async () => {
1157
+ const snapshot = await snapshots.get(name);
1158
+ return snapshot?.status === "saving" ? void 0 : snapshot ?? null;
1159
+ });
1160
+ if (saved?.status !== "ready") throw new Error(`Boat template ${name} save ${saved ? "failed" : "vanished"}`);
1161
+ log(`template ${name} saved at ${elapsed()}`);
1162
+ } finally {
1163
+ if (sandboxId) await sandboxes.delete(sandboxId);
1164
+ }
1165
+ }
1166
+ async function shareBoatRuntimeTemplate(options) {
1167
+ const { snapshots } = clients(options);
1168
+ const created = await snapshots.share(await boatRuntimeTemplateName(options.image, options.prefix));
1169
+ return { template: { image: options.image, shareCode: created.shareCode }, shareId: created.share.id };
1170
+ }
1171
+ async function revokeBoatRuntimeTemplateShares(options, deployedShareId) {
1172
+ const { snapshots } = clients(options);
1173
+ const name = await boatRuntimeTemplateName(options.image, options.prefix);
1174
+ const shares = (await snapshots.shares(name)).filter((share2) => share2.status === "active");
1175
+ if (!shares.some((share2) => share2.id === deployedShareId)) throw new Error(`Boat template ${name} has no active share ${deployedShareId}`);
1176
+ const revoked = shares.filter((share2) => share2.id !== deployedShareId).map((share2) => share2.id);
1177
+ for (const id of revoked) await snapshots.revokeShare(name, id);
1178
+ return revoked;
1179
+ }
1180
+ async function pruneBoatRuntimeTemplates(options) {
1181
+ const { snapshots } = clients(options);
1182
+ const prefix = options.prefix ?? DEFAULT_PREFIX;
1183
+ const current = await boatRuntimeTemplateName(options.image, prefix);
1184
+ const others = (await snapshots.list()).filter((snapshot) => snapshot.name.startsWith(prefix) && snapshot.name !== current && snapshot.status !== "saving").sort((left, right) => right.createdAt.localeCompare(left.createdAt));
1185
+ const removed = others.slice(Math.max(0, (options.keep ?? DEFAULT_KEEP) - 1)).map((snapshot) => snapshot.name);
1186
+ for (const name of removed) await snapshots.delete(name);
1187
+ if (removed.length) options.log?.(`pruned templates ${removed.join(", ")}`);
1188
+ return removed;
1189
+ }
1190
+ export {
1191
+ boatRuntimeTemplateName,
1192
+ ensureBoatRuntimeTemplate,
1193
+ pruneBoatRuntimeTemplates,
1194
+ revokeBoatRuntimeTemplateShares,
1195
+ shareBoatRuntimeTemplate
1196
+ };