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