@claxedo/sandbox-manager 0.7.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +245 -42
  2. package/dist/checkpoint-manager.d.ts +29 -4
  3. package/dist/checkpoint-manager.mjs +133 -24
  4. package/dist/command.d.ts +9 -2
  5. package/dist/command.mjs +8 -8
  6. package/dist/contract.d.ts +687 -0
  7. package/dist/driver-billing.d.ts +29 -0
  8. package/dist/driver-catalog.d.ts +43 -58
  9. package/dist/driver-catalog.mjs +192 -229
  10. package/dist/drivers/boat-client.d.ts +95 -0
  11. package/dist/drivers/boat-named-snapshots.d.ts +31 -0
  12. package/dist/drivers/boat-template.d.ts +49 -0
  13. package/dist/drivers/boat-template.mjs +1196 -0
  14. package/dist/drivers/boat.d.ts +64 -0
  15. package/dist/drivers/boat.mjs +990 -0
  16. package/dist/drivers/cloudflare.d.ts +5 -8
  17. package/dist/drivers/cloudflare.mjs +364 -213
  18. package/dist/drivers/docker.d.ts +4 -3
  19. package/dist/drivers/docker.mjs +244 -165
  20. package/dist/drivers/local-brokering.d.ts +14 -0
  21. package/dist/drivers/local-brokering.mjs +1029 -0
  22. package/dist/drivers/modal.d.ts +4 -7
  23. package/dist/drivers/modal.mjs +220 -155
  24. package/dist/drivers/transient-error.d.ts +13 -0
  25. package/dist/drivers/vercel.d.ts +21 -23
  26. package/dist/drivers/vercel.mjs +279 -169
  27. package/dist/egress-policy.d.ts +134 -0
  28. package/dist/egress-warnings.d.ts +22 -0
  29. package/dist/garbage-collect.d.ts +14 -0
  30. package/dist/hosted-network-policy.d.ts +11 -4
  31. package/dist/hosted-network-policy.mjs +221 -4
  32. package/dist/image-name.d.ts +12 -2
  33. package/dist/image-name.mjs +43 -0
  34. package/dist/image.d.ts +1 -20
  35. package/dist/image.mjs +11 -66
  36. package/dist/index.d.ts +8 -533
  37. package/dist/index.mjs +1420 -315
  38. package/dist/json.d.ts +13 -0
  39. package/dist/lease-failure.d.ts +173 -0
  40. package/dist/lease-lifecycle.d.ts +65 -0
  41. package/dist/lease-retry-wait.d.ts +8 -0
  42. package/dist/lease-start.d.ts +21 -0
  43. package/dist/lease-types.d.ts +15 -5
  44. package/dist/manager.d.ts +2 -0
  45. package/dist/runtime-env.d.ts +15 -2
  46. package/dist/runtime-env.mjs +50 -1
  47. package/dist/runtime-snapshot.d.ts +49 -0
  48. package/dist/runtime-version.d.ts +9 -0
  49. package/dist/runtime-version.mjs +2 -0
  50. package/dist/service-url-exposure.d.ts +1 -3
  51. package/dist/service-url-exposure.mjs +1 -26
  52. package/dist/start-fresh.d.ts +17 -0
  53. package/dist/start-telemetry.d.ts +37 -0
  54. package/dist/stores/memory.d.ts +1 -1
  55. package/dist/stores/memory.mjs +65 -54
  56. package/package.json +67 -81
  57. package/dist/daytona-allow-list.d.ts +0 -16
  58. package/dist/daytona-allow-list.mjs +0 -11
  59. package/dist/drivers/box.d.ts +0 -52
  60. package/dist/drivers/box.mjs +0 -499
  61. package/dist/drivers/cloudflare-egress.d.ts +0 -40
  62. package/dist/drivers/cloudflare-egress.mjs +0 -113
  63. package/dist/drivers/daytona.d.ts +0 -127
  64. package/dist/drivers/daytona.mjs +0 -541
  65. package/dist/drivers/exe.d.ts +0 -26
  66. package/dist/drivers/exe.mjs +0 -469
  67. package/dist/drivers/fetch-bridge.d.ts +0 -10
  68. package/dist/drivers/fetch-bridge.mjs +0 -111
  69. package/dist/lease-policy.d.ts +0 -48
  70. package/dist/lease-policy.mjs +0 -280
@@ -1,95 +1,93 @@
1
1
  // src/constants.ts
2
2
  var DEFAULT_WORKSPACE_RUNTIME_PORT = 2593;
3
3
 
4
- // src/hosted-network-policy.ts
5
- var SANDBOX_MODEL_PROVIDER_HOSTS = [
6
- "api.anthropic.com",
7
- "api.openai.com",
8
- "generativelanguage.googleapis.com",
9
- "openrouter.ai",
10
- // Model catalog + engine config, fetched on runner startup.
11
- "models.dev",
12
- "opencode.ai"
13
- ];
14
- var SANDBOX_PACKAGE_REGISTRY_HOSTS = [
15
- "registry.npmjs.org",
16
- "nodejs.org",
17
- "pypi.org",
18
- "files.pythonhosted.org",
19
- "crates.io",
20
- "static.crates.io",
21
- "index.crates.io",
22
- "proxy.golang.org",
23
- "sum.golang.org"
24
- ];
25
- var SANDBOX_BASELINE_EGRESS_HOSTS = [
26
- ...SANDBOX_MODEL_PROVIDER_HOSTS,
27
- ...SANDBOX_PACKAGE_REGISTRY_HOSTS
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: "Modal",
80
- credentialFields: [
81
- { key: "token_id", label: "Token ID", secret: true },
82
- { key: "token_secret", label: "Token Secret", secret: true }
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
- // Modal can cut the network entirely (`blockNetwork`) but cannot express
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: "Vercel",
107
- credentialFields: [
108
- { key: "access_token", label: "Access Token", secret: true },
109
- { key: "team_id", label: "Team ID" },
110
- { key: "project_id", label: "Project ID" }
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
- // Vercel's sandbox firewall takes a hostname allow list (or "deny-all"),
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: "Cloudflare",
134
- credentialFields: [
135
- { key: "api_token", label: "API Token", secret: true },
136
- { key: "worker_url", label: "Worker URL" }
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: "not-supported",
137
+ hostStopBehavior: "terminates-host",
141
138
  hostResumeBehavior: "same-host",
139
+ hostWatchesIdle: true,
142
140
  targetAccess: "relay",
143
- secretBrokering: "proxy",
144
- // The Cloudflare Sandbox Worker exposes no egress filter: its broker
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: "directories",
147
+ capture: "filesystem",
154
148
  clone: false,
155
149
  captureSource: "preserved",
156
150
  retention: "provider-managed",
157
- restoreMount: "copy-on-write"
151
+ restoreMount: "new-resource"
158
152
  }
159
153
  }
160
154
  },
161
- box: {
162
- id: "box",
163
- label: "Box",
164
- credentialFields: [{ key: "api_key", label: "API Key", secret: true }],
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: "Docker",
185
- credentialFields: [{ key: "image", label: "Image" }],
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 WORKSPACE_DIRECTORY_LABEL = "claxedo.workspaceDirectory";
229
- var RUNTIME_DATA_DIR = "/var/lib/claxedo";
230
- function captureDirectories(workspaceDirectory) {
231
- return [workspaceDirectory, RUNTIME_DATA_DIR];
232
- }
233
- function cleanUrl(input) {
234
- return input.replace(/\/+$/, "");
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 doFetch = options.fetch ?? fetch;
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 base = cleanUrl(options.workerUrl);
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 res = await doFetch(url, {
260
- method,
261
- headers,
262
- ...method === "POST" ? { body: JSON.stringify(body) } : {},
263
- signal: AbortSignal.timeout(timeoutMs)
264
- });
265
- const data = await res.json().catch(() => ({}));
266
- return { status: res.status, data };
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
- const env = {
273
- ...workspaceRuntimeTargetEnv({
274
- workspaceId: input.workspaceId,
275
- hostId,
276
- directory: workspaceDirectory(input),
277
- port: runtimePort,
278
- host: "0.0.0.0"
279
- }),
280
- ...workspaceRuntimeSourceEnv({ source: input.source }),
281
- // Must match RUNTIME_DATA_DIR's capture set below — see its comment.
282
- CLAXEDO_DATA_DIR: RUNTIME_DATA_DIR,
283
- ...input.env,
284
- ...await options.env?.(input, { id: hostId })
285
- };
286
- if (options.runner) env.WORKSPACE_RUNTIME_RUNNER = options.runner;
287
- if (options.controlEnv?.relayJwksUrl) env.WORKSPACE_RUNTIME_RELAY_JWKS_URL = options.controlEnv.relayJwksUrl;
288
- if (options.controlEnv?.relayVerifyPem) env.WORKSPACE_RUNTIME_RELAY_HOST_VERIFY_PEM = options.controlEnv.relayVerifyPem;
289
- if (options.controlEnv?.managementJwksUrl) env.WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL = options.controlEnv.managementJwksUrl;
290
- return env;
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 (!input.secrets?.length) return void 0;
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 \u2014 the egress proxy injects the value as an HTTP 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
- return { hosts: secret.hosts, header: secret.header, value: secret.value };
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: { backupId: input.bootSource.snapshotId, directories: captureDirectories(workspaceDirectory(input)) } } : {},
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: 2e3 };
462
+ if (!response) return { provisioning: true, retryAfterMs: 0 };
329
463
  const { status, data } = response;
330
- if (status >= 500) return { provisioning: true, retryAfterMs: 2e3 };
331
- if (status >= 400 || !data?.url) {
332
- throw new Error(`Cloudflare ensure-runtime failed (${status}): ${data?.error ?? "no runtime url"}`);
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: data.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, checked against the installed SDK
357
- // (@cloudflare/sandbox 0.8.9) rather than assumed:
495
+ // Why a registry is REQUIRED here:
358
496
  //
359
- // 1. The SDK has no enumeration surface. `getSandbox(ns, id)` is
360
- // get-or-create against a Durable Object; nothing in its exports lists
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 Worker predating that route (or deployed with no R2 binding) answers
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 res = await doFetch(`${base}/sandboxes`, {
382
- method: "GET",
383
- headers,
384
- signal: AbortSignal.timeout(timeoutMs)
385
- });
386
- const data = await res.json().catch(() => ({}));
387
- if (res.status === 404 || res.status === 501 || data?.supported === false) {
388
- throw new CloudflareSandboxListingUnsupportedError(
389
- data?.error ?? `Cloudflare sandbox Worker cannot enumerate sandboxes (${res.status}) \u2014 deploy a Worker with the /sandboxes registry route and an R2 BACKUP_BUCKET binding`
390
- );
391
- }
392
- if (!res.ok) {
393
- throw new Error(`Cloudflare sandbox listing failed (${res.status}): ${data?.error ?? "unknown error"}`);
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: "not-supported",
556
+ hostStopBehavior: "terminates-host",
417
557
  hostResumeBehavior: "same-host",
558
+ hostWatchesIdle: true,
418
559
  targetAccess: "relay",
419
- secretBrokering: "proxy",
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 }).catch(() => void 0);
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
- void target;
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 directory = target.labels?.[WORKSPACE_DIRECTORY_LABEL] ?? workspaceDir;
438
- const { status, data } = await call(
439
- target.sandboxId,
440
- "backup",
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: data.backupId };
597
+ return { snapshotId };
447
598
  }
448
599
  };
449
600
  }