@claxedo/sandbox-manager 0.9.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 (67) hide show
  1. package/README.md +233 -26
  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 +42 -7
  9. package/dist/driver-catalog.mjs +115 -161
  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 +3 -3
  17. package/dist/drivers/cloudflare.mjs +208 -147
  18. package/dist/drivers/docker.d.ts +4 -3
  19. package/dist/drivers/docker.mjs +154 -126
  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 +2 -2
  23. package/dist/drivers/modal.mjs +121 -111
  24. package/dist/drivers/transient-error.d.ts +2 -0
  25. package/dist/drivers/vercel.d.ts +3 -2
  26. package/dist/drivers/vercel.mjs +151 -115
  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 +9 -2
  31. package/dist/hosted-network-policy.mjs +218 -1
  32. package/dist/image-name.d.ts +12 -2
  33. package/dist/image-name.mjs +14 -5
  34. package/dist/image.d.ts +1 -20
  35. package/dist/image.mjs +11 -88
  36. package/dist/index.d.ts +8 -578
  37. package/dist/index.mjs +1379 -320
  38. package/dist/lease-failure.d.ts +173 -0
  39. package/dist/lease-lifecycle.d.ts +65 -0
  40. package/dist/lease-retry-wait.d.ts +8 -0
  41. package/dist/lease-start.d.ts +21 -0
  42. package/dist/lease-types.d.ts +4 -3
  43. package/dist/manager.d.ts +2 -0
  44. package/dist/runtime-env.d.ts +4 -2
  45. package/dist/runtime-env.mjs +37 -2
  46. package/dist/runtime-snapshot.d.ts +49 -0
  47. package/dist/runtime-version.d.ts +9 -0
  48. package/dist/runtime-version.mjs +2 -0
  49. package/dist/service-url-exposure.d.ts +0 -1
  50. package/dist/service-url-exposure.mjs +0 -9
  51. package/dist/start-fresh.d.ts +17 -0
  52. package/dist/start-telemetry.d.ts +37 -0
  53. package/dist/stores/memory.d.ts +1 -1
  54. package/dist/stores/memory.mjs +63 -52
  55. package/package.json +26 -42
  56. package/dist/daytona-allow-list.d.ts +0 -16
  57. package/dist/daytona-allow-list.mjs +0 -11
  58. package/dist/drivers/box.d.ts +0 -52
  59. package/dist/drivers/box.mjs +0 -574
  60. package/dist/drivers/daytona.d.ts +0 -163
  61. package/dist/drivers/daytona.mjs +0 -683
  62. package/dist/drivers/exe.d.ts +0 -26
  63. package/dist/drivers/exe.mjs +0 -492
  64. package/dist/drivers/fetch-bridge.d.ts +0 -10
  65. package/dist/drivers/fetch-bridge.mjs +0 -126
  66. package/dist/lease-policy.d.ts +0 -52
  67. package/dist/lease-policy.mjs +0 -290
@@ -1,4 +1,4 @@
1
- import { type SandboxDriver, type SandboxDriverEnsureInput, type SandboxListingUnsupported } from "..";
1
+ import { type SandboxDriver, type SandboxDriverEnsureInput, type SandboxListingUnsupported } from "../contract";
2
2
  import { type WorkspaceRuntimeControlEnv } from "../runtime-env";
3
3
  export type CloudflareSandboxDriverOptions = {
4
4
  /** Base URL of the deployed Cloudflare Sandbox Worker (e.g. https://sbx.example.com). */
@@ -20,8 +20,8 @@ export type CloudflareSandboxDriverOptions = {
20
20
  * managementJwksUrl → WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL (config apply)
21
21
  */
22
22
  controlEnv?: WorkspaceRuntimeControlEnv;
23
- /** Default runner injected as WORKSPACE_RUNTIME_RUNNER, for example "opencode". */
24
- runner?: string;
23
+ /** Default native harness injected as WORKSPACE_RUNTIME_NATIVE_HARNESS, for example "opencode". */
24
+ nativeHarness?: string;
25
25
  /** Dynamic runtime env that needs the sandbox id or current lease. */
26
26
  env?: (input: SandboxDriverEnsureInput, sandbox: {
27
27
  id: string;
@@ -1,44 +1,41 @@
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
- ];
29
-
30
- // src/index.ts
31
- function brokeredSecretPlaceholder(name) {
32
- return `claxedo-broker:${name}`;
33
- }
34
- function brokeredPlaceholderEnv(secrets) {
35
- return Object.fromEntries((secrets ?? []).map((secret) => [secret.name, brokeredSecretPlaceholder(secret.name)]));
36
- }
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
+ };
37
32
 
38
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
+ }
39
38
  var sandboxDriverCredentialFields = {
40
- exe: [{ key: "api_token", label: "API Token", secret: true }],
41
- daytona: [{ key: "api_key", label: "API Key", secret: true }],
42
39
  modal: [
43
40
  { key: "token_id", label: "Token ID", secret: true },
44
41
  { key: "token_secret", label: "Token Secret", secret: true }
@@ -52,68 +49,38 @@ var sandboxDriverCredentialFields = {
52
49
  { key: "api_token", label: "API Token", secret: true },
53
50
  { key: "worker_url", label: "Worker URL" }
54
51
  ],
55
- box: [{ key: "api_key", label: "API Key", secret: true }],
52
+ boat: [{ key: "api_key", label: "API Key", secret: true }],
56
53
  docker: [{ key: "image", label: "Image" }]
57
54
  };
58
55
  var sandboxDriverLabels = {
59
- exe: "exe.dev",
60
- daytona: "Daytona",
61
56
  modal: "Modal",
62
57
  vercel: "Vercel",
63
58
  cloudflare: "Cloudflare",
64
- box: "Box",
59
+ boat: "Boat",
65
60
  docker: "Docker"
66
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
+ }
67
74
 
68
75
  // src/driver-catalog.ts
69
76
  var sandboxDriverCatalog = {
70
- exe: {
71
- id: "exe",
72
- label: sandboxDriverLabels.exe,
73
- credentialFields: sandboxDriverCredentialFields.exe,
74
- metadata: {
75
- driverRunsIn: ["worker", "node"],
76
- hostStopBehavior: "not-supported",
77
- hostResumeBehavior: "same-host",
78
- targetAccess: "relay",
79
- secretBrokering: "none",
80
- egressControl: "none",
81
- persistence: {
82
- resume: "same-sandbox",
83
- capture: "none",
84
- clone: true,
85
- captureSource: "not-applicable",
86
- retention: "provider-managed",
87
- restoreMount: "same-resource"
88
- }
89
- }
90
- },
91
- daytona: {
92
- id: "daytona",
93
- label: sandboxDriverLabels.daytona,
94
- credentialFields: sandboxDriverCredentialFields.daytona,
95
- metadata: {
96
- driverRunsIn: ["worker", "node"],
97
- hostStopBehavior: "suspends-host",
98
- hostResumeBehavior: "same-host",
99
- targetAccess: "relay",
100
- secretBrokering: "native",
101
- // The driver translates names to domainAllowList, or addresses to networkAllowList.
102
- egressControl: "hosts-and-cidrs",
103
- persistence: {
104
- resume: "same-sandbox",
105
- capture: "filesystem",
106
- clone: false,
107
- captureSource: "preserved",
108
- retention: "provider-managed",
109
- restoreMount: "new-resource"
110
- }
111
- }
112
- },
113
77
  modal: {
114
78
  id: "modal",
115
79
  label: sandboxDriverLabels.modal,
116
80
  credentialFields: sandboxDriverCredentialFields.modal,
81
+ runtimeNetwork: { controlPlane: "direct", relay: "configured" },
82
+ runtimeEnv: "callback",
83
+ preparedImage: true,
117
84
  metadata: {
118
85
  driverRunsIn: ["node"],
119
86
  hostStopBehavior: "terminates-host",
@@ -136,6 +103,9 @@ var sandboxDriverCatalog = {
136
103
  id: "vercel",
137
104
  label: sandboxDriverLabels.vercel,
138
105
  credentialFields: sandboxDriverCredentialFields.vercel,
106
+ runtimeNetwork: { controlPlane: "direct", relay: "configured" },
107
+ runtimeEnv: "callback",
108
+ preparedImage: false,
139
109
  metadata: {
140
110
  driverRunsIn: ["node"],
141
111
  hostStopBehavior: "terminates-host",
@@ -158,30 +128,40 @@ var sandboxDriverCatalog = {
158
128
  id: "cloudflare",
159
129
  label: sandboxDriverLabels.cloudflare,
160
130
  credentialFields: sandboxDriverCredentialFields.cloudflare,
131
+ runtimeNetwork: { controlPlane: "direct", relay: "configured" },
132
+ runtimeEnv: "callback",
133
+ preparedImage: false,
134
+ billing: cloudflareBilling,
161
135
  metadata: {
162
136
  driverRunsIn: ["worker"],
163
- hostStopBehavior: "not-supported",
137
+ hostStopBehavior: "terminates-host",
164
138
  hostResumeBehavior: "same-host",
139
+ hostWatchesIdle: true,
165
140
  targetAccess: "relay",
166
141
  secretBrokering: "native",
167
142
  // Credential handlers do not restrict unrelated destinations.
168
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.
169
145
  persistence: {
170
146
  resume: "replacement-restore",
171
- capture: "directories",
147
+ capture: "filesystem",
172
148
  clone: false,
173
149
  captureSource: "preserved",
174
150
  retention: "provider-managed",
175
- restoreMount: "copy-on-write"
151
+ restoreMount: "new-resource"
176
152
  }
177
153
  }
178
154
  },
179
- box: {
180
- id: "box",
181
- label: sandboxDriverLabels.box,
182
- credentialFields: sandboxDriverCredentialFields.box,
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,
183
163
  metadata: {
184
- driverRunsIn: ["node"],
164
+ driverRunsIn: ["worker", "node"],
185
165
  hostStopBehavior: "suspends-host",
186
166
  hostResumeBehavior: "same-host",
187
167
  targetAccess: "relay",
@@ -194,13 +174,17 @@ var sandboxDriverCatalog = {
194
174
  captureSource: "not-applicable",
195
175
  retention: "provider-managed",
196
176
  restoreMount: "same-resource"
197
- }
177
+ },
178
+ machineClasses: ["small", "default", "large"]
198
179
  }
199
180
  },
200
181
  docker: {
201
182
  id: "docker",
202
183
  label: sandboxDriverLabels.docker,
203
184
  credentialFields: sandboxDriverCredentialFields.docker,
185
+ runtimeNetwork: { controlPlane: "docker-host", relay: "configured" },
186
+ runtimeEnv: "callback",
187
+ preparedImage: true,
204
188
  metadata: {
205
189
  driverRunsIn: ["local"],
206
190
  hostStopBehavior: "terminates-host",
@@ -220,6 +204,37 @@ var sandboxDriverCatalog = {
220
204
  }
221
205
  };
222
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
+
223
238
  // ../claxedo-helpers/dist/guards.mjs
224
239
  function isRecord(value) {
225
240
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -234,13 +249,43 @@ function text(input) {
234
249
  }
235
250
 
236
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
+ ]);
237
281
  function workspaceRuntimeBootEnv(input) {
282
+ assertWorkspaceRuntimeIdentityEnv(input.env);
238
283
  const env = {
239
284
  ...workspaceRuntimeTargetEnv(input),
240
285
  ...workspaceRuntimeSourceEnv(input),
241
286
  ...input.env
242
287
  };
243
- if (input.runner) env.WORKSPACE_RUNTIME_RUNNER = input.runner;
288
+ if (input.nativeHarness) env.WORKSPACE_RUNTIME_NATIVE_HARNESS = input.nativeHarness;
244
289
  if (input.controlEnv?.relayJwksUrl) env.WORKSPACE_RUNTIME_RELAY_JWKS_URL = input.controlEnv.relayJwksUrl;
245
290
  if (input.controlEnv?.relayVerifyPem) env.WORKSPACE_RUNTIME_RELAY_HOST_VERIFY_PEM = input.controlEnv.relayVerifyPem;
246
291
  if (input.controlEnv?.managementJwksUrl) env.WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL = input.controlEnv.managementJwksUrl;
@@ -265,21 +310,29 @@ function workspaceRuntimeSourceEnv(input) {
265
310
  };
266
311
  }
267
312
 
313
+ // src/runtime-version.ts
314
+ var MINIMUM_RESTORABLE_WORKSPACE_RUNTIME_VERSION = "0.5.2";
315
+
268
316
  // src/drivers/cloudflare.ts
269
317
  function registryEntries(input) {
270
- if (!Array.isArray(input)) return [];
271
- return input.flatMap((item) => {
318
+ if (!Array.isArray(input)) throw new Error("Invalid Cloudflare sandbox listing: expected sandboxes array");
319
+ return input.map((item) => {
272
320
  const row = asRecord(item);
273
- if (!row) return [];
274
- const entries = Object.entries(row).filter((entry) => typeof entry[1] === "string");
275
- return [Object.fromEntries(entries)];
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);
276
329
  });
277
330
  }
278
331
  var DEFAULT_WORKSPACE_DIR = "/workspace";
279
332
  var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
280
333
  var DEFAULT_TIMEOUT_MS = 45e3;
281
334
  var DEFAULT_ENSURE_TIMEOUT_MS = 3e5;
282
- var WORKSPACE_DIRECTORY_LABEL = "claxedo.workspaceDirectory";
335
+ var DEFAULT_SNAPSHOT_TIMEOUT_MS = 18e4;
283
336
  function deadlineSignal(timeoutMs) {
284
337
  const controller = new AbortController();
285
338
  const timer = setTimeout(
@@ -293,13 +346,7 @@ function deadlineSignal(timeoutMs) {
293
346
  }
294
347
  };
295
348
  }
296
- var RUNTIME_DATA_DIR = "/var/lib/claxedo";
297
- function captureDirectories(workspaceDirectory) {
298
- return [workspaceDirectory, RUNTIME_DATA_DIR];
299
- }
300
- function cleanUrl(input) {
301
- return input.replace(/\/+$/, "");
302
- }
349
+ var RUNTIME_HOME = "/home/claxedo";
303
350
  function sandboxIdFor(workspaceId) {
304
351
  return `claxedo-${workspaceId}`;
305
352
  }
@@ -311,20 +358,26 @@ var CloudflareSandboxListingUnsupportedError = class extends Error {
311
358
  }
312
359
  };
313
360
  function createCloudflareSandboxDriver(options) {
314
- 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
+ };
315
367
  const runtimePort = options.runtimePort ?? DEFAULT_WORKSPACE_RUNTIME_PORT;
316
368
  const runtimeCommand = options.runtimeCommand ?? DEFAULT_RUNTIME_COMMAND;
317
369
  const workspaceDir = options.workspaceDir ?? DEFAULT_WORKSPACE_DIR;
318
370
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
319
371
  const ensureTimeoutMs = options.timeoutMs ?? DEFAULT_ENSURE_TIMEOUT_MS;
320
- const base = cleanUrl(options.workerUrl);
372
+ const snapshotTimeoutMs = options.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS;
373
+ const base = cloudflareWorkerBaseUrl(options.workerUrl);
321
374
  const headers = {
322
375
  Authorization: `Bearer ${options.apiToken}`,
323
376
  "Content-Type": "application/json"
324
377
  };
325
378
  async function call(sandboxId, action, body, method = "POST") {
326
379
  const url = `${base}/sandbox/${encodeURIComponent(sandboxId)}${action ? `/${action}` : ""}`;
327
- const deadline = deadlineSignal(action === "ensure-runtime" ? ensureTimeoutMs : timeoutMs);
380
+ const deadline = deadlineSignal(action === "ensure-runtime" ? ensureTimeoutMs : action === "snapshot" ? snapshotTimeoutMs : timeoutMs);
328
381
  try {
329
382
  const res = await doFetch(url, {
330
383
  method,
@@ -350,15 +403,15 @@ function createCloudflareSandboxDriver(options) {
350
403
  host: "0.0.0.0",
351
404
  source: input.source,
352
405
  env: {
353
- // Must match the checkpoint capture set below.
354
- CLAXEDO_DATA_DIR: RUNTIME_DATA_DIR,
406
+ HOME: RUNTIME_HOME,
407
+ CLAXEDO_DATA_DIR: `${RUNTIME_HOME}/.claxedo`,
355
408
  ...input.env,
356
409
  ...await options.env?.(input, { id: hostId }),
357
410
  // Last, so no caller-supplied variable of the same name can stand in
358
411
  // for a placeholder the outbound handler matches on.
359
412
  ...brokeredPlaceholderEnv(input.secrets)
360
413
  },
361
- runner: options.runner,
414
+ nativeHarness: options.nativeHarness,
362
415
  controlEnv: options.controlEnv
363
416
  });
364
417
  }
@@ -399,21 +452,26 @@ function createCloudflareSandboxDriver(options) {
399
452
  // what makes this driver's `list()` (and therefore GC) possible at all.
400
453
  // Not container env — labels are control-plane metadata.
401
454
  labels: input.labels,
402
- ...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 } : {},
403
456
  ...egress ? { egress } : {}
404
457
  }
405
458
  ).catch((error) => {
406
459
  if (error instanceof Error && error.name === "TimeoutError") return void 0;
407
460
  throw error;
408
461
  });
409
- if (!response) return { provisioning: true, retryAfterMs: 2e3 };
462
+ if (!response) return { provisioning: true, retryAfterMs: 0 };
410
463
  const { status, data } = response;
411
464
  const runtimeUrl = text(data.url);
412
465
  if (status === 503 && data.error === "workspace-runtime did not become ready") {
413
- return { provisioning: true, retryAfterMs: 2e3 };
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.");
414
471
  }
415
472
  if (status >= 400 || !runtimeUrl) {
416
- throw new Error(`Cloudflare ensure-runtime failed (${status}): ${text(data.error) ?? "no runtime url"}`);
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);
417
475
  }
418
476
  const targetOut = {
419
477
  workspaceId: input.workspaceId,
@@ -425,10 +483,7 @@ function createCloudflareSandboxDriver(options) {
425
483
  id: "cloudflare",
426
484
  resourceId: sandboxId
427
485
  },
428
- labels: {
429
- ...input.labels,
430
- [WORKSPACE_DIRECTORY_LABEL]: workspaceDirectory(input)
431
- }
486
+ labels: input.labels
432
487
  };
433
488
  return targetOut;
434
489
  }
@@ -437,12 +492,10 @@ function createCloudflareSandboxDriver(options) {
437
492
  // Provider-state enumeration, served by the sandbox Worker's registry
438
493
  // (`GET /sandboxes`) rather than by Cloudflare's own APIs.
439
494
  //
440
- // Why a registry is REQUIRED here, checked against the installed SDK
441
- // (@cloudflare/sandbox 0.8.9) rather than assumed:
495
+ // Why a registry is REQUIRED here:
442
496
  //
443
- // 1. The SDK has no enumeration surface. `getSandbox(ns, id)` is
444
- // get-or-create against a Durable Object; nothing in its exports lists
445
- // 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.
446
499
  // 2. The account-level REST list-objects endpoint returns
447
500
  // `{ id: <hex>, hasStoredData }` and nothing else. `idFromName` is
448
501
  // one-way, so a hex id cannot be mapped back to `claxedo-<workspaceId>`
@@ -455,12 +508,7 @@ function createCloudflareSandboxDriver(options) {
455
508
  // So the Worker records each sandbox on `ensure-runtime` and drops it on
456
509
  // destroy — the pattern Cloudflare recommends for precisely this reason.
457
510
  //
458
- // A Worker predating that route (or deployed with no R2 binding) answers
459
- // 404/501, which THROWS `CloudflareSandboxListingUnsupportedError` rather
460
- // than returning `[]`. An empty list from an old Worker would mean "nothing
461
- // is orphaned" and hand GC a silent success — the defect this whole
462
- // workstream removes. The manager turns that throw back into
463
- // `listingUnsupported`, so an un-upgraded Worker is loudly visible.
511
+ // A missing registry route or R2 binding cannot establish an empty inventory.
464
512
  async list() {
465
513
  const deadline = deadlineSignal(timeoutMs);
466
514
  try {
@@ -469,10 +517,14 @@ function createCloudflareSandboxDriver(options) {
469
517
  headers,
470
518
  signal: deadline.signal
471
519
  });
472
- const data = asRecord(await res.json().catch(() => ({}))) ?? {};
473
- if (res.status === 404 || res.status === 501 || data.supported === false) {
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) {
474
526
  throw new CloudflareSandboxListingUnsupportedError(
475
- text(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`
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`
476
528
  );
477
529
  }
478
530
  if (!res.ok) {
@@ -480,7 +532,6 @@ function createCloudflareSandboxDriver(options) {
480
532
  }
481
533
  return registryEntries(data.sandboxes).flatMap((entry) => {
482
534
  const sandboxId = entry.sandboxId;
483
- if (!sandboxId) return [];
484
535
  const { sandboxId: _id, ...labels } = entry;
485
536
  const target = {
486
537
  ...labels.workspaceId ? { workspaceId: labels.workspaceId } : {},
@@ -502,8 +553,9 @@ function createCloudflareSandboxDriver(options) {
502
553
  },
503
554
  metadata: {
504
555
  driverRunsIn: ["worker"],
505
- hostStopBehavior: "not-supported",
556
+ hostStopBehavior: "terminates-host",
506
557
  hostResumeBehavior: "same-host",
558
+ hostWatchesIdle: true,
507
559
  targetAccess: "relay",
508
560
  secretBrokering: "native",
509
561
  egressControl: "none",
@@ -511,10 +563,24 @@ function createCloudflareSandboxDriver(options) {
511
563
  },
512
564
  ensureHost,
513
565
  async touch(target) {
514
- 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
+ }
515
570
  },
516
571
  async stop(target) {
517
- 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";
518
584
  },
519
585
  async destroy(target) {
520
586
  const { status } = await call(target.sandboxId, "", {}, "DELETE");
@@ -523,17 +589,12 @@ function createCloudflareSandboxDriver(options) {
523
589
  }
524
590
  },
525
591
  async snapshot(target) {
526
- const directory = target.labels?.[WORKSPACE_DIRECTORY_LABEL] ?? workspaceDir;
527
- const { status, data } = await call(
528
- target.sandboxId,
529
- "backup",
530
- { directories: captureDirectories(directory) }
531
- );
532
- const backupId = text(data.backupId);
533
- if (status >= 400 || !backupId) {
534
- throw new Error(`Cloudflare backup failed (${status}): ${text(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"}`);
535
596
  }
536
- return { snapshotId: backupId };
597
+ return { snapshotId };
537
598
  }
538
599
  };
539
600
  }
@@ -1,17 +1,18 @@
1
- import type { SandboxDriver, SandboxDriverEnsureInput } from "..";
1
+ import type { SandboxDriver, SandboxDriverEnsureInput } from "../contract";
2
2
  type SandboxEnv = Record<string, string | undefined>;
3
3
  export type DockerCommandResult = {
4
4
  stdout?: string | Buffer;
5
5
  stderr?: string | Buffer;
6
6
  };
7
- export type DockerCommandRunner = (args: string[], timeoutMs?: number) => Promise<DockerCommandResult>;
7
+ /** `env` joins the docker CLI's own environment; a `--env NAME` argument with no value reads NAME from it. */
8
+ export type DockerCommandRunner = (args: string[], timeoutMs?: number, env?: Record<string, string>) => Promise<DockerCommandResult>;
8
9
  export type DockerHealthFetch = (input: string, init?: RequestInit) => Promise<Response>;
9
10
  export type DockerSandboxDriverOptions = {
10
11
  image: string;
11
12
  runtimePort?: number;
12
13
  runtimeCommand?: string;
13
14
  workspaceDir?: string;
14
- runner?: string;
15
+ nativeHarness?: string;
15
16
  env?: (input: SandboxDriverEnsureInput, host: {
16
17
  id: string;
17
18
  }) => Record<string, string> | Promise<Record<string, string>>;