@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
|
@@ -1,95 +1,93 @@
|
|
|
1
1
|
// src/constants.ts
|
|
2
2
|
var DEFAULT_WORKSPACE_RUNTIME_PORT = 2593;
|
|
3
3
|
|
|
4
|
-
// src/
|
|
5
|
-
var
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
4
|
+
// src/driver-billing.ts
|
|
5
|
+
var HOUR_SECONDS = 3600;
|
|
6
|
+
var boatBilling = {
|
|
7
|
+
source: { url: "https://docs.boat.dev/pricing", readOn: "2026-10-12" },
|
|
8
|
+
meter: "Boat bills machine time per second while a sandbox runs, at an hourly rate per size; a stopped sandbox costs nothing.",
|
|
9
|
+
rates: {
|
|
10
|
+
small: { size: "small", vcpu: 2, memoryGb: 4, usdPerHour: 0.018 },
|
|
11
|
+
default: { size: "default", vcpu: 4, memoryGb: 8, usdPerHour: 0.036 },
|
|
12
|
+
large: { size: "large", vcpu: 8, memoryGb: 16, usdPerHour: 0.072 }
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var CLOUDFLARE_USD_PER_GIB_SECOND_MEMORY = 25e-7;
|
|
16
|
+
var CLOUDFLARE_USD_PER_GB_SECOND_DISK = 7e-8;
|
|
17
|
+
var CLOUDFLARE_USD_PER_VCPU_SECOND_ACTIVE = 2e-5;
|
|
18
|
+
var CLOUDFLARE_STANDARD_1 = { size: "standard-1", vcpu: 0.5, memoryGb: 4, diskGb: 8 };
|
|
19
|
+
var cloudflareBilling = {
|
|
20
|
+
source: { url: "https://developers.cloudflare.com/containers/pricing/", readOn: "2026-10-12" },
|
|
21
|
+
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.",
|
|
22
|
+
rates: {
|
|
23
|
+
default: {
|
|
24
|
+
size: CLOUDFLARE_STANDARD_1.size,
|
|
25
|
+
vcpu: CLOUDFLARE_STANDARD_1.vcpu,
|
|
26
|
+
memoryGb: CLOUDFLARE_STANDARD_1.memoryGb,
|
|
27
|
+
usdPerHour: (CLOUDFLARE_STANDARD_1.memoryGb * CLOUDFLARE_USD_PER_GIB_SECOND_MEMORY + CLOUDFLARE_STANDARD_1.diskGb * CLOUDFLARE_USD_PER_GB_SECOND_DISK) * HOUR_SECONDS
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
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`
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ../sandbox-contract/dist/index.mjs
|
|
34
|
+
var sandboxCheckpointUnusableCauses = ["expired", "runtime_outdated", "runtime_modified", "not_restorable"];
|
|
35
|
+
function isSandboxCheckpointUnusableCause(value) {
|
|
36
|
+
return sandboxCheckpointUnusableCauses.some((cause) => cause === value);
|
|
37
|
+
}
|
|
38
|
+
var sandboxDriverCredentialFields = {
|
|
39
|
+
modal: [
|
|
40
|
+
{ key: "token_id", label: "Token ID", secret: true },
|
|
41
|
+
{ key: "token_secret", label: "Token Secret", secret: true }
|
|
42
|
+
],
|
|
43
|
+
vercel: [
|
|
44
|
+
{ key: "access_token", label: "Access Token", secret: true },
|
|
45
|
+
{ key: "team_id", label: "Team ID" },
|
|
46
|
+
{ key: "project_id", label: "Project ID" }
|
|
47
|
+
],
|
|
48
|
+
cloudflare: [
|
|
49
|
+
{ key: "api_token", label: "API Token", secret: true },
|
|
50
|
+
{ key: "worker_url", label: "Worker URL" }
|
|
51
|
+
],
|
|
52
|
+
boat: [{ key: "api_key", label: "API Key", secret: true }],
|
|
53
|
+
docker: [{ key: "image", label: "Image" }]
|
|
54
|
+
};
|
|
55
|
+
var sandboxDriverLabels = {
|
|
56
|
+
modal: "Modal",
|
|
57
|
+
vercel: "Vercel",
|
|
58
|
+
cloudflare: "Cloudflare",
|
|
59
|
+
boat: "Boat",
|
|
60
|
+
docker: "Docker"
|
|
61
|
+
};
|
|
62
|
+
function cloudflareWorkerBaseUrl(input) {
|
|
63
|
+
let url;
|
|
64
|
+
try {
|
|
65
|
+
url = new URL(input.trim());
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error("Cloudflare Worker URL must be a valid HTTPS endpoint");
|
|
68
|
+
}
|
|
69
|
+
if (url.protocol !== "https:" || url.username || url.password || input.includes("?") || input.includes("#")) {
|
|
70
|
+
throw new Error("Cloudflare Worker URL requires HTTPS without credentials, query or fragment");
|
|
71
|
+
}
|
|
72
|
+
return url.origin + url.pathname.replace(/\/+$/, "");
|
|
73
|
+
}
|
|
29
74
|
|
|
30
75
|
// src/driver-catalog.ts
|
|
31
76
|
var sandboxDriverCatalog = {
|
|
32
|
-
exe: {
|
|
33
|
-
id: "exe",
|
|
34
|
-
label: "exe.dev",
|
|
35
|
-
credentialFields: [{ key: "api_token", label: "API Token", secret: true }],
|
|
36
|
-
metadata: {
|
|
37
|
-
driverRunsIn: ["worker", "node"],
|
|
38
|
-
hostStopBehavior: "not-supported",
|
|
39
|
-
hostResumeBehavior: "same-host",
|
|
40
|
-
targetAccess: "relay",
|
|
41
|
-
secretBrokering: "none",
|
|
42
|
-
egressControl: "none",
|
|
43
|
-
persistence: {
|
|
44
|
-
resume: "same-sandbox",
|
|
45
|
-
capture: "none",
|
|
46
|
-
clone: true,
|
|
47
|
-
captureSource: "not-applicable",
|
|
48
|
-
retention: "provider-managed",
|
|
49
|
-
restoreMount: "same-resource"
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
daytona: {
|
|
54
|
-
id: "daytona",
|
|
55
|
-
label: "Daytona",
|
|
56
|
-
credentialFields: [{ key: "api_key", label: "API Key", secret: true }],
|
|
57
|
-
metadata: {
|
|
58
|
-
driverRunsIn: ["worker", "node"],
|
|
59
|
-
hostStopBehavior: "suspends-host",
|
|
60
|
-
hostResumeBehavior: "same-host",
|
|
61
|
-
targetAccess: "relay",
|
|
62
|
-
secretBrokering: "native",
|
|
63
|
-
// Daytona is the only driver that can filter egress by NAME as well as
|
|
64
|
-
// by address: `domainAllowList` (names) alongside `networkAllowList`
|
|
65
|
-
// (CIDRs), with `networkBlockAll` as the deny-all floor.
|
|
66
|
-
egressControl: "hosts-and-cidrs",
|
|
67
|
-
persistence: {
|
|
68
|
-
resume: "same-sandbox",
|
|
69
|
-
capture: "filesystem",
|
|
70
|
-
clone: false,
|
|
71
|
-
captureSource: "preserved",
|
|
72
|
-
retention: "provider-managed",
|
|
73
|
-
restoreMount: "new-resource"
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
77
|
modal: {
|
|
78
78
|
id: "modal",
|
|
79
|
-
label:
|
|
80
|
-
credentialFields:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
label: sandboxDriverLabels.modal,
|
|
80
|
+
credentialFields: sandboxDriverCredentialFields.modal,
|
|
81
|
+
runtimeNetwork: { controlPlane: "direct", relay: "configured" },
|
|
82
|
+
runtimeEnv: "callback",
|
|
83
|
+
preparedImage: true,
|
|
84
84
|
metadata: {
|
|
85
85
|
driverRunsIn: ["node"],
|
|
86
86
|
hostStopBehavior: "terminates-host",
|
|
87
87
|
hostResumeBehavior: "replacement-host",
|
|
88
88
|
targetAccess: "relay",
|
|
89
89
|
secretBrokering: "none",
|
|
90
|
-
//
|
|
91
|
-
// an allowlist, and its driver throws on a host policy. A blackout is
|
|
92
|
-
// not containment for a workspace that has to clone and reach a model.
|
|
90
|
+
// The driver does not install Modal's domain allowlist or alpha sidecar.
|
|
93
91
|
egressControl: "none",
|
|
94
92
|
persistence: {
|
|
95
93
|
resume: "replacement-restore",
|
|
@@ -103,20 +101,18 @@ var sandboxDriverCatalog = {
|
|
|
103
101
|
},
|
|
104
102
|
vercel: {
|
|
105
103
|
id: "vercel",
|
|
106
|
-
label:
|
|
107
|
-
credentialFields:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
],
|
|
104
|
+
label: sandboxDriverLabels.vercel,
|
|
105
|
+
credentialFields: sandboxDriverCredentialFields.vercel,
|
|
106
|
+
runtimeNetwork: { controlPlane: "direct", relay: "configured" },
|
|
107
|
+
runtimeEnv: "callback",
|
|
108
|
+
preparedImage: false,
|
|
112
109
|
metadata: {
|
|
113
110
|
driverRunsIn: ["node"],
|
|
114
111
|
hostStopBehavior: "terminates-host",
|
|
115
112
|
hostResumeBehavior: "replacement-host",
|
|
116
113
|
targetAccess: "relay",
|
|
117
114
|
secretBrokering: "native",
|
|
118
|
-
//
|
|
119
|
-
// never CIDRs.
|
|
115
|
+
// The driver translates hosts only; the SDK's subnet rules are not wired to net.cidrs.
|
|
120
116
|
egressControl: "hosts",
|
|
121
117
|
persistence: {
|
|
122
118
|
resume: "replacement-restore",
|
|
@@ -130,40 +126,42 @@ var sandboxDriverCatalog = {
|
|
|
130
126
|
},
|
|
131
127
|
cloudflare: {
|
|
132
128
|
id: "cloudflare",
|
|
133
|
-
label:
|
|
134
|
-
credentialFields:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
129
|
+
label: sandboxDriverLabels.cloudflare,
|
|
130
|
+
credentialFields: sandboxDriverCredentialFields.cloudflare,
|
|
131
|
+
runtimeNetwork: { controlPlane: "direct", relay: "configured" },
|
|
132
|
+
runtimeEnv: "callback",
|
|
133
|
+
preparedImage: false,
|
|
134
|
+
billing: cloudflareBilling,
|
|
138
135
|
metadata: {
|
|
139
136
|
driverRunsIn: ["worker"],
|
|
140
|
-
hostStopBehavior: "
|
|
137
|
+
hostStopBehavior: "terminates-host",
|
|
141
138
|
hostResumeBehavior: "same-host",
|
|
139
|
+
hostWatchesIdle: true,
|
|
142
140
|
targetAccess: "relay",
|
|
143
|
-
secretBrokering: "
|
|
144
|
-
//
|
|
145
|
-
// (drivers/cloudflare-egress.ts) is an OPT-IN proxy the sandbox chooses
|
|
146
|
-
// to route brokered-credential requests through, not a boundary. The
|
|
147
|
-
// driver therefore drops `net` entirely — which is exactly why this
|
|
148
|
-
// must be declared, so the manager refuses instead of provisioning a
|
|
149
|
-
// sandbox the caller believes is contained.
|
|
141
|
+
secretBrokering: "native",
|
|
142
|
+
// Credential handlers do not restrict unrelated destinations.
|
|
150
143
|
egressControl: "none",
|
|
144
|
+
// A container snapshot holds the whole root filesystem; the platform keeps it 30 days past its last restore and offers no deletion.
|
|
151
145
|
persistence: {
|
|
152
146
|
resume: "replacement-restore",
|
|
153
|
-
capture: "
|
|
147
|
+
capture: "filesystem",
|
|
154
148
|
clone: false,
|
|
155
149
|
captureSource: "preserved",
|
|
156
150
|
retention: "provider-managed",
|
|
157
|
-
restoreMount: "
|
|
151
|
+
restoreMount: "new-resource"
|
|
158
152
|
}
|
|
159
153
|
}
|
|
160
154
|
},
|
|
161
|
-
|
|
162
|
-
id: "
|
|
163
|
-
label:
|
|
164
|
-
credentialFields:
|
|
155
|
+
boat: {
|
|
156
|
+
id: "boat",
|
|
157
|
+
label: sandboxDriverLabels.boat,
|
|
158
|
+
credentialFields: sandboxDriverCredentialFields.boat,
|
|
159
|
+
runtimeNetwork: { controlPlane: "direct", relay: "configured" },
|
|
160
|
+
runtimeEnv: "callback",
|
|
161
|
+
preparedImage: true,
|
|
162
|
+
billing: boatBilling,
|
|
165
163
|
metadata: {
|
|
166
|
-
driverRunsIn: ["node"],
|
|
164
|
+
driverRunsIn: ["worker", "node"],
|
|
167
165
|
hostStopBehavior: "suspends-host",
|
|
168
166
|
hostResumeBehavior: "same-host",
|
|
169
167
|
targetAccess: "relay",
|
|
@@ -176,13 +174,17 @@ var sandboxDriverCatalog = {
|
|
|
176
174
|
captureSource: "not-applicable",
|
|
177
175
|
retention: "provider-managed",
|
|
178
176
|
restoreMount: "same-resource"
|
|
179
|
-
}
|
|
177
|
+
},
|
|
178
|
+
machineClasses: ["small", "default", "large"]
|
|
180
179
|
}
|
|
181
180
|
},
|
|
182
181
|
docker: {
|
|
183
182
|
id: "docker",
|
|
184
|
-
label:
|
|
185
|
-
credentialFields:
|
|
183
|
+
label: sandboxDriverLabels.docker,
|
|
184
|
+
credentialFields: sandboxDriverCredentialFields.docker,
|
|
185
|
+
runtimeNetwork: { controlPlane: "docker-host", relay: "configured" },
|
|
186
|
+
runtimeEnv: "callback",
|
|
187
|
+
preparedImage: true,
|
|
186
188
|
metadata: {
|
|
187
189
|
driverRunsIn: ["local"],
|
|
188
190
|
hostStopBehavior: "terminates-host",
|
|
@@ -202,7 +204,94 @@ var sandboxDriverCatalog = {
|
|
|
202
204
|
}
|
|
203
205
|
};
|
|
204
206
|
|
|
207
|
+
// src/egress-policy.ts
|
|
208
|
+
function brokeredSecretPlaceholder(name) {
|
|
209
|
+
return `claxedo-broker:${name}`;
|
|
210
|
+
}
|
|
211
|
+
function brokeredPlaceholderEnv(secrets) {
|
|
212
|
+
return Object.fromEntries((secrets ?? []).map((secret) => [secret.name, brokeredSecretPlaceholder(secret.name)]));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/lease-failure.ts
|
|
216
|
+
var SandboxRuntimeBootError = class extends Error {
|
|
217
|
+
runtimeBootFailed = true;
|
|
218
|
+
constructor(reason) {
|
|
219
|
+
super(reason);
|
|
220
|
+
this.name = "SandboxRuntimeBootError";
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
var SandboxCheckpointUnusableError = class extends Error {
|
|
224
|
+
constructor(checkpointUnusable, reason) {
|
|
225
|
+
super(reason);
|
|
226
|
+
this.checkpointUnusable = checkpointUnusable;
|
|
227
|
+
this.name = "SandboxCheckpointUnusableError";
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
var SandboxProviderKeyRefusedError = class extends Error {
|
|
231
|
+
providerKeyRefused = true;
|
|
232
|
+
constructor(reason) {
|
|
233
|
+
super(reason);
|
|
234
|
+
this.name = "SandboxProviderKeyRefusedError";
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// ../claxedo-helpers/dist/guards.mjs
|
|
239
|
+
function isRecord(value) {
|
|
240
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
241
|
+
}
|
|
242
|
+
function asRecord(value) {
|
|
243
|
+
return isRecord(value) ? value : void 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/json.ts
|
|
247
|
+
function text(input) {
|
|
248
|
+
return typeof input === "string" && input.length > 0 ? input : void 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
205
251
|
// src/runtime-env.ts
|
|
252
|
+
var WORKSPACE_RUNTIME_IDENTITY_ENV_KEYS = [
|
|
253
|
+
"WORKSPACE_RUNTIME_WORKSPACE_ID",
|
|
254
|
+
"WORKSPACE_RUNTIME_HOST_ID",
|
|
255
|
+
"WORKSPACE_RUNTIME_RELAY_WORKSPACE_IDS"
|
|
256
|
+
];
|
|
257
|
+
function workspaceRuntimeIdentityEnvConflicts(env) {
|
|
258
|
+
if (!env) return [];
|
|
259
|
+
return WORKSPACE_RUNTIME_IDENTITY_ENV_KEYS.filter((key) => env[key] !== void 0);
|
|
260
|
+
}
|
|
261
|
+
function assertWorkspaceRuntimeIdentityEnv(env) {
|
|
262
|
+
const conflicts = workspaceRuntimeIdentityEnvConflicts(env);
|
|
263
|
+
if (conflicts.length === 0) return;
|
|
264
|
+
throw new Error(
|
|
265
|
+
`sandbox env cannot set runtime identity ${conflicts.join(", ")}: the driver's placement owns the identity its target reports and the lease records`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
var WORKSPACE_RUNTIME_PUBLIC_ENV_KEYS = /* @__PURE__ */ new Set([
|
|
269
|
+
...WORKSPACE_RUNTIME_IDENTITY_ENV_KEYS,
|
|
270
|
+
"WORKSPACE_RUNTIME_DIRECTORY",
|
|
271
|
+
"WORKSPACE_RUNTIME_PORT",
|
|
272
|
+
"WORKSPACE_RUNTIME_HOST",
|
|
273
|
+
"WORKSPACE_RUNTIME_SOURCE_KIND",
|
|
274
|
+
"WORKSPACE_RUNTIME_GIT_REPO_URL",
|
|
275
|
+
"WORKSPACE_RUNTIME_GIT_BRANCH",
|
|
276
|
+
"WORKSPACE_RUNTIME_NATIVE_HARNESS",
|
|
277
|
+
"WORKSPACE_RUNTIME_RELAY_JWKS_URL",
|
|
278
|
+
"WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL",
|
|
279
|
+
"WORKSPACE_RUNTIME_SESSION_AUTHORITY_URL"
|
|
280
|
+
]);
|
|
281
|
+
function workspaceRuntimeBootEnv(input) {
|
|
282
|
+
assertWorkspaceRuntimeIdentityEnv(input.env);
|
|
283
|
+
const env = {
|
|
284
|
+
...workspaceRuntimeTargetEnv(input),
|
|
285
|
+
...workspaceRuntimeSourceEnv(input),
|
|
286
|
+
...input.env
|
|
287
|
+
};
|
|
288
|
+
if (input.nativeHarness) env.WORKSPACE_RUNTIME_NATIVE_HARNESS = input.nativeHarness;
|
|
289
|
+
if (input.controlEnv?.relayJwksUrl) env.WORKSPACE_RUNTIME_RELAY_JWKS_URL = input.controlEnv.relayJwksUrl;
|
|
290
|
+
if (input.controlEnv?.relayVerifyPem) env.WORKSPACE_RUNTIME_RELAY_HOST_VERIFY_PEM = input.controlEnv.relayVerifyPem;
|
|
291
|
+
if (input.controlEnv?.managementJwksUrl) env.WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL = input.controlEnv.managementJwksUrl;
|
|
292
|
+
if (input.controlEnv?.sessionAuthorityUrl) env.WORKSPACE_RUNTIME_SESSION_AUTHORITY_URL = input.controlEnv.sessionAuthorityUrl;
|
|
293
|
+
return env;
|
|
294
|
+
}
|
|
206
295
|
function workspaceRuntimeTargetEnv(input) {
|
|
207
296
|
return {
|
|
208
297
|
WORKSPACE_RUNTIME_WORKSPACE_ID: input.workspaceId,
|
|
@@ -221,18 +310,43 @@ function workspaceRuntimeSourceEnv(input) {
|
|
|
221
310
|
};
|
|
222
311
|
}
|
|
223
312
|
|
|
313
|
+
// src/runtime-version.ts
|
|
314
|
+
var MINIMUM_RESTORABLE_WORKSPACE_RUNTIME_VERSION = "0.5.2";
|
|
315
|
+
|
|
224
316
|
// src/drivers/cloudflare.ts
|
|
317
|
+
function registryEntries(input) {
|
|
318
|
+
if (!Array.isArray(input)) throw new Error("Invalid Cloudflare sandbox listing: expected sandboxes array");
|
|
319
|
+
return input.map((item) => {
|
|
320
|
+
const row = asRecord(item);
|
|
321
|
+
if (!row || typeof row.sandboxId !== "string" || !row.sandboxId) {
|
|
322
|
+
throw new Error("Invalid Cloudflare sandbox listing entry: expected sandboxId");
|
|
323
|
+
}
|
|
324
|
+
const entries = Object.entries(row).map(([key, value]) => {
|
|
325
|
+
if (typeof value !== "string") throw new Error(`Invalid Cloudflare sandbox listing label: ${key}`);
|
|
326
|
+
return [key, value];
|
|
327
|
+
});
|
|
328
|
+
return Object.fromEntries(entries);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
225
331
|
var DEFAULT_WORKSPACE_DIR = "/workspace";
|
|
226
332
|
var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
|
|
227
333
|
var DEFAULT_TIMEOUT_MS = 45e3;
|
|
228
|
-
var
|
|
229
|
-
var
|
|
230
|
-
function
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
334
|
+
var DEFAULT_ENSURE_TIMEOUT_MS = 3e5;
|
|
335
|
+
var DEFAULT_SNAPSHOT_TIMEOUT_MS = 18e4;
|
|
336
|
+
function deadlineSignal(timeoutMs) {
|
|
337
|
+
const controller = new AbortController();
|
|
338
|
+
const timer = setTimeout(
|
|
339
|
+
() => controller.abort(new DOMException("The operation timed out", "TimeoutError")),
|
|
340
|
+
timeoutMs
|
|
341
|
+
);
|
|
342
|
+
return {
|
|
343
|
+
signal: controller.signal,
|
|
344
|
+
cleanup() {
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
235
348
|
}
|
|
349
|
+
var RUNTIME_HOME = "/home/claxedo";
|
|
236
350
|
function sandboxIdFor(workspaceId) {
|
|
237
351
|
return `claxedo-${workspaceId}`;
|
|
238
352
|
}
|
|
@@ -244,63 +358,83 @@ var CloudflareSandboxListingUnsupportedError = class extends Error {
|
|
|
244
358
|
}
|
|
245
359
|
};
|
|
246
360
|
function createCloudflareSandboxDriver(options) {
|
|
247
|
-
const
|
|
361
|
+
const fetchWorker = options.fetch ?? fetch;
|
|
362
|
+
const doFetch = async (url, init) => {
|
|
363
|
+
const response = await fetchWorker(url, { ...init, redirect: "manual" });
|
|
364
|
+
if (response.redirected || response.status >= 300 && response.status < 400) throw new Error("Cloudflare Worker redirects are not allowed");
|
|
365
|
+
return response;
|
|
366
|
+
};
|
|
248
367
|
const runtimePort = options.runtimePort ?? DEFAULT_WORKSPACE_RUNTIME_PORT;
|
|
249
368
|
const runtimeCommand = options.runtimeCommand ?? DEFAULT_RUNTIME_COMMAND;
|
|
250
369
|
const workspaceDir = options.workspaceDir ?? DEFAULT_WORKSPACE_DIR;
|
|
251
370
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
252
|
-
const
|
|
371
|
+
const ensureTimeoutMs = options.timeoutMs ?? DEFAULT_ENSURE_TIMEOUT_MS;
|
|
372
|
+
const snapshotTimeoutMs = options.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS;
|
|
373
|
+
const base = cloudflareWorkerBaseUrl(options.workerUrl);
|
|
253
374
|
const headers = {
|
|
254
375
|
Authorization: `Bearer ${options.apiToken}`,
|
|
255
376
|
"Content-Type": "application/json"
|
|
256
377
|
};
|
|
257
378
|
async function call(sandboxId, action, body, method = "POST") {
|
|
258
379
|
const url = `${base}/sandbox/${encodeURIComponent(sandboxId)}${action ? `/${action}` : ""}`;
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
380
|
+
const deadline = deadlineSignal(action === "ensure-runtime" ? ensureTimeoutMs : action === "snapshot" ? snapshotTimeoutMs : timeoutMs);
|
|
381
|
+
try {
|
|
382
|
+
const res = await doFetch(url, {
|
|
383
|
+
method,
|
|
384
|
+
headers,
|
|
385
|
+
...method === "POST" ? { body: JSON.stringify(body) } : {},
|
|
386
|
+
signal: deadline.signal
|
|
387
|
+
});
|
|
388
|
+
const data = asRecord(await res.json().catch(() => ({}))) ?? {};
|
|
389
|
+
return { status: res.status, data };
|
|
390
|
+
} finally {
|
|
391
|
+
deadline.cleanup();
|
|
392
|
+
}
|
|
267
393
|
}
|
|
268
394
|
function workspaceDirectory(input) {
|
|
269
395
|
return input.workspaceRoot ?? workspaceDir;
|
|
270
396
|
}
|
|
271
397
|
async function bootEnv(input, hostId) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
398
|
+
return workspaceRuntimeBootEnv({
|
|
399
|
+
workspaceId: input.workspaceId,
|
|
400
|
+
hostId,
|
|
401
|
+
directory: workspaceDirectory(input),
|
|
402
|
+
port: runtimePort,
|
|
403
|
+
host: "0.0.0.0",
|
|
404
|
+
source: input.source,
|
|
405
|
+
env: {
|
|
406
|
+
HOME: RUNTIME_HOME,
|
|
407
|
+
CLAXEDO_DATA_DIR: `${RUNTIME_HOME}/.claxedo`,
|
|
408
|
+
...input.env,
|
|
409
|
+
...await options.env?.(input, { id: hostId }),
|
|
410
|
+
// Last, so no caller-supplied variable of the same name can stand in
|
|
411
|
+
// for a placeholder the outbound handler matches on.
|
|
412
|
+
...brokeredPlaceholderEnv(input.secrets)
|
|
413
|
+
},
|
|
414
|
+
nativeHarness: options.nativeHarness,
|
|
415
|
+
controlEnv: options.controlEnv
|
|
416
|
+
});
|
|
291
417
|
}
|
|
292
418
|
function egressRegistrations(input) {
|
|
293
|
-
if (
|
|
419
|
+
if (input.secrets === void 0) return void 0;
|
|
294
420
|
return input.secrets.map((secret) => {
|
|
295
421
|
if (!secret.header) {
|
|
296
422
|
throw new Error(
|
|
297
|
-
`cloudflare brokered secret "${secret.name}" requires a header
|
|
423
|
+
`cloudflare brokered secret "${secret.name}" requires a header for native outbound injection`
|
|
298
424
|
);
|
|
299
425
|
}
|
|
300
426
|
if (secret.hosts.length === 0) {
|
|
301
427
|
throw new Error(`cloudflare brokered secret "${secret.name}" requires at least one host in its egress allowlist`);
|
|
302
428
|
}
|
|
303
|
-
|
|
429
|
+
const value = secret.scheme ? `${secret.scheme} ${secret.value}` : secret.value;
|
|
430
|
+
return {
|
|
431
|
+
name: secret.name,
|
|
432
|
+
hosts: secret.hosts,
|
|
433
|
+
header: secret.header,
|
|
434
|
+
value,
|
|
435
|
+
methods: [...secret.methods ?? []],
|
|
436
|
+
pathPrefixes: [...secret.pathPrefixes ?? []]
|
|
437
|
+
};
|
|
304
438
|
});
|
|
305
439
|
}
|
|
306
440
|
async function ensureHost(input) {
|
|
@@ -318,33 +452,38 @@ function createCloudflareSandboxDriver(options) {
|
|
|
318
452
|
// what makes this driver's `list()` (and therefore GC) possible at all.
|
|
319
453
|
// Not container env — labels are control-plane metadata.
|
|
320
454
|
labels: input.labels,
|
|
321
|
-
...input.bootSource?.kind === "driver-snapshot" ? { restore: {
|
|
455
|
+
...input.bootSource?.kind === "driver-snapshot" ? { restore: { snapshotId: input.bootSource.snapshotId }, minimumRuntimeVersion: MINIMUM_RESTORABLE_WORKSPACE_RUNTIME_VERSION } : {},
|
|
322
456
|
...egress ? { egress } : {}
|
|
323
457
|
}
|
|
324
458
|
).catch((error) => {
|
|
325
459
|
if (error instanceof Error && error.name === "TimeoutError") return void 0;
|
|
326
460
|
throw error;
|
|
327
461
|
});
|
|
328
|
-
if (!response) return { provisioning: true, retryAfterMs:
|
|
462
|
+
if (!response) return { provisioning: true, retryAfterMs: 0 };
|
|
329
463
|
const { status, data } = response;
|
|
330
|
-
|
|
331
|
-
if (status
|
|
332
|
-
|
|
464
|
+
const runtimeUrl = text(data.url);
|
|
465
|
+
if (status === 503 && data.error === "workspace-runtime did not become ready") {
|
|
466
|
+
return { provisioning: true, retryAfterMs: 0 };
|
|
467
|
+
}
|
|
468
|
+
if (data.exited === true) throw new SandboxRuntimeBootError(text(data.error) ?? "The workspace runtime exited before it was ready");
|
|
469
|
+
if (status === 410 && isSandboxCheckpointUnusableCause(data.checkpointUnusable)) {
|
|
470
|
+
throw new SandboxCheckpointUnusableError(data.checkpointUnusable, text(data.error) ?? "This workspace's saved files cannot be restored; choose Start fresh.");
|
|
471
|
+
}
|
|
472
|
+
if (status >= 400 || !runtimeUrl) {
|
|
473
|
+
const reason = `Cloudflare ensure-runtime failed (${status}): ${text(data.error) ?? "no runtime url"}`;
|
|
474
|
+
throw status === 401 || status === 403 ? new SandboxProviderKeyRefusedError(reason) : new Error(reason);
|
|
333
475
|
}
|
|
334
476
|
const targetOut = {
|
|
335
477
|
workspaceId: input.workspaceId,
|
|
336
478
|
sandboxId,
|
|
337
|
-
url:
|
|
479
|
+
url: runtimeUrl,
|
|
338
480
|
hostId,
|
|
339
481
|
driverResourceId: sandboxId,
|
|
340
482
|
driver: {
|
|
341
483
|
id: "cloudflare",
|
|
342
484
|
resourceId: sandboxId
|
|
343
485
|
},
|
|
344
|
-
labels:
|
|
345
|
-
...input.labels,
|
|
346
|
-
[WORKSPACE_DIRECTORY_LABEL]: workspaceDirectory(input)
|
|
347
|
-
}
|
|
486
|
+
labels: input.labels
|
|
348
487
|
};
|
|
349
488
|
return targetOut;
|
|
350
489
|
}
|
|
@@ -353,12 +492,10 @@ function createCloudflareSandboxDriver(options) {
|
|
|
353
492
|
// Provider-state enumeration, served by the sandbox Worker's registry
|
|
354
493
|
// (`GET /sandboxes`) rather than by Cloudflare's own APIs.
|
|
355
494
|
//
|
|
356
|
-
// Why a registry is REQUIRED here
|
|
357
|
-
// (@cloudflare/sandbox 0.8.9) rather than assumed:
|
|
495
|
+
// Why a registry is REQUIRED here:
|
|
358
496
|
//
|
|
359
|
-
// 1.
|
|
360
|
-
// get-or-create
|
|
361
|
-
// a namespace. `listDurableObjectIds` is `cloudflare:test`-only.
|
|
497
|
+
// 1. A Durable Object namespace has no enumeration surface: `getByName`
|
|
498
|
+
// is get-or-create, and `listDurableObjectIds` is `cloudflare:test`-only.
|
|
362
499
|
// 2. The account-level REST list-objects endpoint returns
|
|
363
500
|
// `{ id: <hex>, hasStoredData }` and nothing else. `idFromName` is
|
|
364
501
|
// one-way, so a hex id cannot be mapped back to `claxedo-<workspaceId>`
|
|
@@ -371,61 +508,79 @@ function createCloudflareSandboxDriver(options) {
|
|
|
371
508
|
// So the Worker records each sandbox on `ensure-runtime` and drops it on
|
|
372
509
|
// destroy — the pattern Cloudflare recommends for precisely this reason.
|
|
373
510
|
//
|
|
374
|
-
// A
|
|
375
|
-
// 404/501, which THROWS `CloudflareSandboxListingUnsupportedError` rather
|
|
376
|
-
// than returning `[]`. An empty list from an old Worker would mean "nothing
|
|
377
|
-
// is orphaned" and hand GC a silent success — the defect this whole
|
|
378
|
-
// workstream removes. The manager turns that throw back into
|
|
379
|
-
// `listingUnsupported`, so an un-upgraded Worker is loudly visible.
|
|
511
|
+
// A missing registry route or R2 binding cannot establish an empty inventory.
|
|
380
512
|
async list() {
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
513
|
+
const deadline = deadlineSignal(timeoutMs);
|
|
514
|
+
try {
|
|
515
|
+
const res = await doFetch(`${base}/sandboxes`, {
|
|
516
|
+
method: "GET",
|
|
517
|
+
headers,
|
|
518
|
+
signal: deadline.signal
|
|
519
|
+
});
|
|
520
|
+
if (res.status === 404 || res.status === 501) {
|
|
521
|
+
throw new CloudflareSandboxListingUnsupportedError(`Cloudflare sandbox Worker cannot enumerate sandboxes (${res.status}); requires the /sandboxes registry route and SANDBOX_REGISTRY binding`);
|
|
522
|
+
}
|
|
523
|
+
const data = asRecord(await res.json());
|
|
524
|
+
if (!data) throw new Error("Invalid Cloudflare sandbox listing envelope");
|
|
525
|
+
if (data.supported === false) {
|
|
526
|
+
throw new CloudflareSandboxListingUnsupportedError(
|
|
527
|
+
text(data.error) ?? `Cloudflare sandbox Worker cannot enumerate sandboxes (${res.status}) \u2014 deploy a Worker with the /sandboxes registry route and an R2 SANDBOX_REGISTRY binding`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (!res.ok) {
|
|
531
|
+
throw new Error(`Cloudflare sandbox listing failed (${res.status}): ${text(data.error) ?? "unknown error"}`);
|
|
532
|
+
}
|
|
533
|
+
return registryEntries(data.sandboxes).flatMap((entry) => {
|
|
534
|
+
const sandboxId = entry.sandboxId;
|
|
535
|
+
const { sandboxId: _id, ...labels } = entry;
|
|
536
|
+
const target = {
|
|
537
|
+
...labels.workspaceId ? { workspaceId: labels.workspaceId } : {},
|
|
538
|
+
sandboxId,
|
|
539
|
+
// Identity, not a reachable address — GC only names what it destroys.
|
|
540
|
+
url: `${base}/sandbox/${encodeURIComponent(sandboxId)}/proxy`,
|
|
541
|
+
// Matches `ensureHost`'s `hostId = sandboxId`, so a live sandbox
|
|
542
|
+
// cannot fail GC's identity check and be destroyed as an orphan.
|
|
543
|
+
hostId: sandboxId,
|
|
544
|
+
driverResourceId: sandboxId,
|
|
545
|
+
labels,
|
|
546
|
+
driver: { id: "cloudflare", resourceId: sandboxId }
|
|
547
|
+
};
|
|
548
|
+
return [target];
|
|
549
|
+
});
|
|
550
|
+
} finally {
|
|
551
|
+
deadline.cleanup();
|
|
394
552
|
}
|
|
395
|
-
return (data.sandboxes ?? []).flatMap((entry) => {
|
|
396
|
-
const sandboxId = entry.sandboxId;
|
|
397
|
-
if (!sandboxId) return [];
|
|
398
|
-
const { sandboxId: _id, ...labels } = entry;
|
|
399
|
-
const target = {
|
|
400
|
-
...labels.workspaceId ? { workspaceId: labels.workspaceId } : {},
|
|
401
|
-
sandboxId,
|
|
402
|
-
// Identity, not a reachable address — GC only names what it destroys.
|
|
403
|
-
url: `${base}/sandbox/${encodeURIComponent(sandboxId)}/proxy`,
|
|
404
|
-
// Matches `ensureHost`'s `hostId = sandboxId`, so a live sandbox
|
|
405
|
-
// cannot fail GC's identity check and be destroyed as an orphan.
|
|
406
|
-
hostId: sandboxId,
|
|
407
|
-
driverResourceId: sandboxId,
|
|
408
|
-
labels,
|
|
409
|
-
driver: { id: "cloudflare", resourceId: sandboxId }
|
|
410
|
-
};
|
|
411
|
-
return [target];
|
|
412
|
-
});
|
|
413
553
|
},
|
|
414
554
|
metadata: {
|
|
415
555
|
driverRunsIn: ["worker"],
|
|
416
|
-
hostStopBehavior: "
|
|
556
|
+
hostStopBehavior: "terminates-host",
|
|
417
557
|
hostResumeBehavior: "same-host",
|
|
558
|
+
hostWatchesIdle: true,
|
|
418
559
|
targetAccess: "relay",
|
|
419
|
-
secretBrokering: "
|
|
560
|
+
secretBrokering: "native",
|
|
420
561
|
egressControl: "none",
|
|
421
562
|
persistence: sandboxDriverCatalog.cloudflare.metadata.persistence
|
|
422
563
|
},
|
|
423
564
|
ensureHost,
|
|
424
565
|
async touch(target) {
|
|
425
|
-
await call(target.sandboxId, "touch-runtime", { port: runtimePort })
|
|
566
|
+
const { status, data } = await call(target.sandboxId, "touch-runtime", { port: runtimePort });
|
|
567
|
+
if (status < 200 || status >= 300 || data.ok !== true) {
|
|
568
|
+
throw new Error(`Cloudflare touch failed (${status}) for ${target.sandboxId}`);
|
|
569
|
+
}
|
|
426
570
|
},
|
|
427
571
|
async stop(target) {
|
|
428
|
-
|
|
572
|
+
const { status, data } = await call(target.sandboxId, "stop", { epoch: target.epoch });
|
|
573
|
+
if (status >= 400 || data.ok !== true) {
|
|
574
|
+
throw new Error(`Cloudflare stop failed (${status}) for ${target.sandboxId}: ${text(data.error) ?? "not stopped"}`);
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
// The sandbox's object outlives its container and keeps the snapshots it took, so a container that is not running is a stopped host, never a gone one.
|
|
578
|
+
async inspect(target) {
|
|
579
|
+
const { status, data } = await call(target.sandboxId, "inspect", {});
|
|
580
|
+
if (status < 200 || status >= 300 || typeof data.running !== "boolean") {
|
|
581
|
+
throw new Error(`Cloudflare inspect failed (${status}) for ${target.sandboxId}: ${text(data.error) ?? "no container state"}`);
|
|
582
|
+
}
|
|
583
|
+
return data.running ? "serving" : "stopped";
|
|
429
584
|
},
|
|
430
585
|
async destroy(target) {
|
|
431
586
|
const { status } = await call(target.sandboxId, "", {}, "DELETE");
|
|
@@ -434,16 +589,12 @@ function createCloudflareSandboxDriver(options) {
|
|
|
434
589
|
}
|
|
435
590
|
},
|
|
436
591
|
async snapshot(target) {
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
"
|
|
441
|
-
{ directories: captureDirectories(directory) }
|
|
442
|
-
);
|
|
443
|
-
if (status >= 400 || !data.backupId) {
|
|
444
|
-
throw new Error(`Cloudflare backup failed (${status}): ${data.error ?? "no backup id"}`);
|
|
592
|
+
const { status, data } = await call(target.sandboxId, "snapshot", {});
|
|
593
|
+
const snapshotId = text(data.snapshotId);
|
|
594
|
+
if (status >= 400 || !snapshotId) {
|
|
595
|
+
throw new Error(`Cloudflare snapshot failed (${status}): ${text(data.error) ?? "no snapshot id"}`);
|
|
445
596
|
}
|
|
446
|
-
return { snapshotId
|
|
597
|
+
return { snapshotId };
|
|
447
598
|
}
|
|
448
599
|
};
|
|
449
600
|
}
|