@claxedo/sandbox-manager 0.6.0 → 0.9.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 (41) hide show
  1. package/README.md +60 -15
  2. package/dist/checkpoint-manager.d.ts +47 -0
  3. package/dist/checkpoint-manager.mjs +116 -0
  4. package/dist/daytona-allow-list.d.ts +11 -0
  5. package/dist/daytona-allow-list.mjs +5 -1
  6. package/dist/driver-catalog.d.ts +5 -52
  7. package/dist/driver-catalog.mjs +263 -121
  8. package/dist/drivers/box.mjs +309 -39
  9. package/dist/drivers/cloudflare.d.ts +18 -7
  10. package/dist/drivers/cloudflare.mjs +423 -34
  11. package/dist/drivers/daytona.d.ts +82 -13
  12. package/dist/drivers/daytona.mjs +495 -45
  13. package/dist/drivers/docker.mjs +284 -38
  14. package/dist/drivers/exe.d.ts +26 -0
  15. package/dist/drivers/exe.mjs +492 -0
  16. package/dist/drivers/fetch-bridge.mjs +29 -5
  17. package/dist/drivers/modal.d.ts +2 -5
  18. package/dist/drivers/modal.mjs +296 -46
  19. package/dist/drivers/transient-error.d.ts +11 -0
  20. package/dist/drivers/vercel.d.ts +27 -11
  21. package/dist/drivers/vercel.mjs +339 -59
  22. package/dist/hosted-network-policy.d.ts +87 -0
  23. package/dist/hosted-network-policy.mjs +61 -0
  24. package/dist/image-name.mjs +34 -0
  25. package/dist/image.d.ts +18 -3
  26. package/dist/image.mjs +47 -32
  27. package/dist/index.d.ts +281 -17
  28. package/dist/index.mjs +393 -26
  29. package/dist/json.d.ts +13 -0
  30. package/dist/lease-policy.d.ts +6 -3
  31. package/dist/lease-policy.mjs +213 -54
  32. package/dist/lease-types.d.ts +17 -3
  33. package/dist/runtime-env.d.ts +12 -1
  34. package/dist/runtime-env.mjs +14 -0
  35. package/dist/runtime-version.mjs +1 -9
  36. package/dist/service-url-exposure.d.ts +1 -2
  37. package/dist/service-url-exposure.mjs +1 -17
  38. package/dist/stores/memory.mjs +39 -4
  39. package/package.json +67 -51
  40. package/dist/drivers/cloudflare-egress.d.ts +0 -40
  41. package/dist/drivers/cloudflare-egress.mjs +0 -113
@@ -1,7 +1,252 @@
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
+ }
37
+
38
+ // ../sandbox-contract/dist/index.mjs
39
+ var sandboxDriverCredentialFields = {
40
+ exe: [{ key: "api_token", label: "API Token", secret: true }],
41
+ daytona: [{ key: "api_key", label: "API Key", secret: true }],
42
+ modal: [
43
+ { key: "token_id", label: "Token ID", secret: true },
44
+ { key: "token_secret", label: "Token Secret", secret: true }
45
+ ],
46
+ vercel: [
47
+ { key: "access_token", label: "Access Token", secret: true },
48
+ { key: "team_id", label: "Team ID" },
49
+ { key: "project_id", label: "Project ID" }
50
+ ],
51
+ cloudflare: [
52
+ { key: "api_token", label: "API Token", secret: true },
53
+ { key: "worker_url", label: "Worker URL" }
54
+ ],
55
+ box: [{ key: "api_key", label: "API Key", secret: true }],
56
+ docker: [{ key: "image", label: "Image" }]
57
+ };
58
+ var sandboxDriverLabels = {
59
+ exe: "exe.dev",
60
+ daytona: "Daytona",
61
+ modal: "Modal",
62
+ vercel: "Vercel",
63
+ cloudflare: "Cloudflare",
64
+ box: "Box",
65
+ docker: "Docker"
66
+ };
67
+
68
+ // src/driver-catalog.ts
69
+ 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
+ modal: {
114
+ id: "modal",
115
+ label: sandboxDriverLabels.modal,
116
+ credentialFields: sandboxDriverCredentialFields.modal,
117
+ metadata: {
118
+ driverRunsIn: ["node"],
119
+ hostStopBehavior: "terminates-host",
120
+ hostResumeBehavior: "replacement-host",
121
+ targetAccess: "relay",
122
+ secretBrokering: "none",
123
+ // The driver does not install Modal's domain allowlist or alpha sidecar.
124
+ egressControl: "none",
125
+ persistence: {
126
+ resume: "replacement-restore",
127
+ capture: "filesystem",
128
+ clone: false,
129
+ captureSource: "preserved",
130
+ retention: "provider-managed",
131
+ restoreMount: "new-resource"
132
+ }
133
+ }
134
+ },
135
+ vercel: {
136
+ id: "vercel",
137
+ label: sandboxDriverLabels.vercel,
138
+ credentialFields: sandboxDriverCredentialFields.vercel,
139
+ metadata: {
140
+ driverRunsIn: ["node"],
141
+ hostStopBehavior: "terminates-host",
142
+ hostResumeBehavior: "replacement-host",
143
+ targetAccess: "relay",
144
+ secretBrokering: "native",
145
+ // The driver translates hosts only; the SDK's subnet rules are not wired to net.cidrs.
146
+ egressControl: "hosts",
147
+ persistence: {
148
+ resume: "replacement-restore",
149
+ capture: "filesystem",
150
+ clone: false,
151
+ captureSource: "stopped",
152
+ retention: "explicit",
153
+ restoreMount: "new-resource"
154
+ }
155
+ }
156
+ },
157
+ cloudflare: {
158
+ id: "cloudflare",
159
+ label: sandboxDriverLabels.cloudflare,
160
+ credentialFields: sandboxDriverCredentialFields.cloudflare,
161
+ metadata: {
162
+ driverRunsIn: ["worker"],
163
+ hostStopBehavior: "not-supported",
164
+ hostResumeBehavior: "same-host",
165
+ targetAccess: "relay",
166
+ secretBrokering: "native",
167
+ // Credential handlers do not restrict unrelated destinations.
168
+ egressControl: "none",
169
+ persistence: {
170
+ resume: "replacement-restore",
171
+ capture: "directories",
172
+ clone: false,
173
+ captureSource: "preserved",
174
+ retention: "provider-managed",
175
+ restoreMount: "copy-on-write"
176
+ }
177
+ }
178
+ },
179
+ box: {
180
+ id: "box",
181
+ label: sandboxDriverLabels.box,
182
+ credentialFields: sandboxDriverCredentialFields.box,
183
+ metadata: {
184
+ driverRunsIn: ["node"],
185
+ hostStopBehavior: "suspends-host",
186
+ hostResumeBehavior: "same-host",
187
+ targetAccess: "relay",
188
+ secretBrokering: "none",
189
+ egressControl: "none",
190
+ persistence: {
191
+ resume: "same-sandbox",
192
+ capture: "none",
193
+ clone: false,
194
+ captureSource: "not-applicable",
195
+ retention: "provider-managed",
196
+ restoreMount: "same-resource"
197
+ }
198
+ }
199
+ },
200
+ docker: {
201
+ id: "docker",
202
+ label: sandboxDriverLabels.docker,
203
+ credentialFields: sandboxDriverCredentialFields.docker,
204
+ metadata: {
205
+ driverRunsIn: ["local"],
206
+ hostStopBehavior: "terminates-host",
207
+ hostResumeBehavior: "same-host",
208
+ targetAccess: "loopback",
209
+ secretBrokering: "none",
210
+ egressControl: "none",
211
+ persistence: {
212
+ resume: "replacement-restore",
213
+ capture: "same-resource",
214
+ clone: false,
215
+ captureSource: "preserved",
216
+ retention: "provider-managed",
217
+ restoreMount: "same-resource"
218
+ }
219
+ }
220
+ }
221
+ };
222
+
223
+ // ../claxedo-helpers/dist/guards.mjs
224
+ function isRecord(value) {
225
+ return value !== null && typeof value === "object" && !Array.isArray(value);
226
+ }
227
+ function asRecord(value) {
228
+ return isRecord(value) ? value : void 0;
229
+ }
230
+
231
+ // src/json.ts
232
+ function text(input) {
233
+ return typeof input === "string" && input.length > 0 ? input : void 0;
234
+ }
235
+
4
236
  // src/runtime-env.ts
237
+ function workspaceRuntimeBootEnv(input) {
238
+ const env = {
239
+ ...workspaceRuntimeTargetEnv(input),
240
+ ...workspaceRuntimeSourceEnv(input),
241
+ ...input.env
242
+ };
243
+ if (input.runner) env.WORKSPACE_RUNTIME_RUNNER = input.runner;
244
+ if (input.controlEnv?.relayJwksUrl) env.WORKSPACE_RUNTIME_RELAY_JWKS_URL = input.controlEnv.relayJwksUrl;
245
+ if (input.controlEnv?.relayVerifyPem) env.WORKSPACE_RUNTIME_RELAY_HOST_VERIFY_PEM = input.controlEnv.relayVerifyPem;
246
+ if (input.controlEnv?.managementJwksUrl) env.WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL = input.controlEnv.managementJwksUrl;
247
+ if (input.controlEnv?.sessionAuthorityUrl) env.WORKSPACE_RUNTIME_SESSION_AUTHORITY_URL = input.controlEnv.sessionAuthorityUrl;
248
+ return env;
249
+ }
5
250
  function workspaceRuntimeTargetEnv(input) {
6
251
  return {
7
252
  WORKSPACE_RUNTIME_WORKSPACE_ID: input.workspaceId,
@@ -21,21 +266,57 @@ function workspaceRuntimeSourceEnv(input) {
21
266
  }
22
267
 
23
268
  // src/drivers/cloudflare.ts
269
+ function registryEntries(input) {
270
+ if (!Array.isArray(input)) return [];
271
+ return input.flatMap((item) => {
272
+ 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)];
276
+ });
277
+ }
24
278
  var DEFAULT_WORKSPACE_DIR = "/workspace";
25
279
  var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
26
280
  var DEFAULT_TIMEOUT_MS = 45e3;
281
+ var DEFAULT_ENSURE_TIMEOUT_MS = 3e5;
282
+ var WORKSPACE_DIRECTORY_LABEL = "claxedo.workspaceDirectory";
283
+ function deadlineSignal(timeoutMs) {
284
+ const controller = new AbortController();
285
+ const timer = setTimeout(
286
+ () => controller.abort(new DOMException("The operation timed out", "TimeoutError")),
287
+ timeoutMs
288
+ );
289
+ return {
290
+ signal: controller.signal,
291
+ cleanup() {
292
+ clearTimeout(timer);
293
+ }
294
+ };
295
+ }
296
+ var RUNTIME_DATA_DIR = "/var/lib/claxedo";
297
+ function captureDirectories(workspaceDirectory) {
298
+ return [workspaceDirectory, RUNTIME_DATA_DIR];
299
+ }
27
300
  function cleanUrl(input) {
28
301
  return input.replace(/\/+$/, "");
29
302
  }
30
303
  function sandboxIdFor(workspaceId) {
31
304
  return `claxedo-${workspaceId}`;
32
305
  }
306
+ var CloudflareSandboxListingUnsupportedError = class extends Error {
307
+ listingUnsupported = true;
308
+ constructor(message) {
309
+ super(message);
310
+ this.name = "CloudflareSandboxListingUnsupportedError";
311
+ }
312
+ };
33
313
  function createCloudflareSandboxDriver(options) {
34
314
  const doFetch = options.fetch ?? fetch;
35
315
  const runtimePort = options.runtimePort ?? DEFAULT_WORKSPACE_RUNTIME_PORT;
36
316
  const runtimeCommand = options.runtimeCommand ?? DEFAULT_RUNTIME_COMMAND;
37
317
  const workspaceDir = options.workspaceDir ?? DEFAULT_WORKSPACE_DIR;
38
318
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
319
+ const ensureTimeoutMs = options.timeoutMs ?? DEFAULT_ENSURE_TIMEOUT_MS;
39
320
  const base = cleanUrl(options.workerUrl);
40
321
  const headers = {
41
322
  Authorization: `Bearer ${options.apiToken}`,
@@ -43,49 +324,64 @@ function createCloudflareSandboxDriver(options) {
43
324
  };
44
325
  async function call(sandboxId, action, body, method = "POST") {
45
326
  const url = `${base}/sandbox/${encodeURIComponent(sandboxId)}${action ? `/${action}` : ""}`;
46
- const res = await doFetch(url, {
47
- method,
48
- headers,
49
- ...method === "POST" ? { body: JSON.stringify(body) } : {},
50
- signal: AbortSignal.timeout(timeoutMs)
51
- });
52
- const data = await res.json().catch(() => ({}));
53
- return { status: res.status, data };
327
+ const deadline = deadlineSignal(action === "ensure-runtime" ? ensureTimeoutMs : timeoutMs);
328
+ try {
329
+ const res = await doFetch(url, {
330
+ method,
331
+ headers,
332
+ ...method === "POST" ? { body: JSON.stringify(body) } : {},
333
+ signal: deadline.signal
334
+ });
335
+ const data = asRecord(await res.json().catch(() => ({}))) ?? {};
336
+ return { status: res.status, data };
337
+ } finally {
338
+ deadline.cleanup();
339
+ }
54
340
  }
55
341
  function workspaceDirectory(input) {
56
342
  return input.workspaceRoot ?? workspaceDir;
57
343
  }
58
344
  async function bootEnv(input, hostId) {
59
- const env = {
60
- ...workspaceRuntimeTargetEnv({
61
- workspaceId: input.workspaceId,
62
- hostId,
63
- directory: workspaceDirectory(input),
64
- port: runtimePort,
65
- host: "0.0.0.0"
66
- }),
67
- ...workspaceRuntimeSourceEnv({ source: input.source }),
68
- ...input.env,
69
- ...await options.env?.(input, { id: hostId })
70
- };
71
- if (options.runner) env.WORKSPACE_RUNTIME_RUNNER = options.runner;
72
- if (options.controlEnv?.relayJwksUrl) env.WORKSPACE_RUNTIME_RELAY_JWKS_URL = options.controlEnv.relayJwksUrl;
73
- if (options.controlEnv?.relayVerifyPem) env.WORKSPACE_RUNTIME_RELAY_HOST_VERIFY_PEM = options.controlEnv.relayVerifyPem;
74
- if (options.controlEnv?.managementJwksUrl) env.WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL = options.controlEnv.managementJwksUrl;
75
- return env;
345
+ return workspaceRuntimeBootEnv({
346
+ workspaceId: input.workspaceId,
347
+ hostId,
348
+ directory: workspaceDirectory(input),
349
+ port: runtimePort,
350
+ host: "0.0.0.0",
351
+ source: input.source,
352
+ env: {
353
+ // Must match the checkpoint capture set below.
354
+ CLAXEDO_DATA_DIR: RUNTIME_DATA_DIR,
355
+ ...input.env,
356
+ ...await options.env?.(input, { id: hostId }),
357
+ // Last, so no caller-supplied variable of the same name can stand in
358
+ // for a placeholder the outbound handler matches on.
359
+ ...brokeredPlaceholderEnv(input.secrets)
360
+ },
361
+ runner: options.runner,
362
+ controlEnv: options.controlEnv
363
+ });
76
364
  }
77
365
  function egressRegistrations(input) {
78
- if (!input.secrets?.length) return void 0;
366
+ if (input.secrets === void 0) return void 0;
79
367
  return input.secrets.map((secret) => {
80
368
  if (!secret.header) {
81
369
  throw new Error(
82
- `cloudflare brokered secret "${secret.name}" requires a header \u2014 the egress proxy injects the value as an HTTP header`
370
+ `cloudflare brokered secret "${secret.name}" requires a header for native outbound injection`
83
371
  );
84
372
  }
85
373
  if (secret.hosts.length === 0) {
86
374
  throw new Error(`cloudflare brokered secret "${secret.name}" requires at least one host in its egress allowlist`);
87
375
  }
88
- return { hosts: secret.hosts, header: secret.header, value: secret.value };
376
+ const value = secret.scheme ? `${secret.scheme} ${secret.value}` : secret.value;
377
+ return {
378
+ name: secret.name,
379
+ hosts: secret.hosts,
380
+ header: secret.header,
381
+ value,
382
+ methods: [...secret.methods ?? []],
383
+ pathPrefixes: [...secret.pathPrefixes ?? []]
384
+ };
89
385
  });
90
386
  }
91
387
  async function ensureHost(input) {
@@ -99,6 +395,11 @@ function createCloudflareSandboxDriver(options) {
99
395
  env: await bootEnv(input, hostId),
100
396
  port: runtimePort,
101
397
  command: runtimeCommand,
398
+ // Sent so the Worker can record them in its sandbox registry, which is
399
+ // what makes this driver's `list()` (and therefore GC) possible at all.
400
+ // Not container env — labels are control-plane metadata.
401
+ labels: input.labels,
402
+ ...input.bootSource?.kind === "driver-snapshot" ? { restore: { backupId: input.bootSource.snapshotId, directories: captureDirectories(workspaceDirectory(input)) } } : {},
102
403
  ...egress ? { egress } : {}
103
404
  }
104
405
  ).catch((error) => {
@@ -107,32 +408,106 @@ function createCloudflareSandboxDriver(options) {
107
408
  });
108
409
  if (!response) return { provisioning: true, retryAfterMs: 2e3 };
109
410
  const { status, data } = response;
110
- if (status >= 500) return { provisioning: true, retryAfterMs: 2e3 };
111
- if (status >= 400 || !data?.url) {
112
- throw new Error(`Cloudflare ensure-runtime failed (${status}): ${data?.error ?? "no runtime url"}`);
411
+ const runtimeUrl = text(data.url);
412
+ if (status === 503 && data.error === "workspace-runtime did not become ready") {
413
+ return { provisioning: true, retryAfterMs: 2e3 };
414
+ }
415
+ if (status >= 400 || !runtimeUrl) {
416
+ throw new Error(`Cloudflare ensure-runtime failed (${status}): ${text(data.error) ?? "no runtime url"}`);
113
417
  }
114
418
  const targetOut = {
115
419
  workspaceId: input.workspaceId,
116
420
  sandboxId,
117
- url: data.url,
421
+ url: runtimeUrl,
118
422
  hostId,
119
423
  driverResourceId: sandboxId,
120
424
  driver: {
121
425
  id: "cloudflare",
122
426
  resourceId: sandboxId
123
427
  },
124
- labels: input.labels
428
+ labels: {
429
+ ...input.labels,
430
+ [WORKSPACE_DIRECTORY_LABEL]: workspaceDirectory(input)
431
+ }
125
432
  };
126
433
  return targetOut;
127
434
  }
128
435
  return {
129
436
  id: "cloudflare",
437
+ // Provider-state enumeration, served by the sandbox Worker's registry
438
+ // (`GET /sandboxes`) rather than by Cloudflare's own APIs.
439
+ //
440
+ // Why a registry is REQUIRED here, checked against the installed SDK
441
+ // (@cloudflare/sandbox 0.8.9) rather than assumed:
442
+ //
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.
446
+ // 2. The account-level REST list-objects endpoint returns
447
+ // `{ id: <hex>, hasStoredData }` and nothing else. `idFromName` is
448
+ // one-way, so a hex id cannot be mapped back to `claxedo-<workspaceId>`
449
+ // — no name means no labels, no `app` ownership check, and no safe
450
+ // destroy. A sweep over hex ids could only guess what it was deleting.
451
+ // 3. It would also need account-level CF credentials this driver
452
+ // deliberately does not hold; it authenticates to one user-deployed
453
+ // Worker with that Worker's own bearer token.
454
+ //
455
+ // So the Worker records each sandbox on `ensure-runtime` and drops it on
456
+ // destroy — the pattern Cloudflare recommends for precisely this reason.
457
+ //
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.
464
+ async list() {
465
+ const deadline = deadlineSignal(timeoutMs);
466
+ try {
467
+ const res = await doFetch(`${base}/sandboxes`, {
468
+ method: "GET",
469
+ headers,
470
+ signal: deadline.signal
471
+ });
472
+ const data = asRecord(await res.json().catch(() => ({}))) ?? {};
473
+ if (res.status === 404 || res.status === 501 || data.supported === false) {
474
+ 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`
476
+ );
477
+ }
478
+ if (!res.ok) {
479
+ throw new Error(`Cloudflare sandbox listing failed (${res.status}): ${text(data.error) ?? "unknown error"}`);
480
+ }
481
+ return registryEntries(data.sandboxes).flatMap((entry) => {
482
+ const sandboxId = entry.sandboxId;
483
+ if (!sandboxId) return [];
484
+ const { sandboxId: _id, ...labels } = entry;
485
+ const target = {
486
+ ...labels.workspaceId ? { workspaceId: labels.workspaceId } : {},
487
+ sandboxId,
488
+ // Identity, not a reachable address — GC only names what it destroys.
489
+ url: `${base}/sandbox/${encodeURIComponent(sandboxId)}/proxy`,
490
+ // Matches `ensureHost`'s `hostId = sandboxId`, so a live sandbox
491
+ // cannot fail GC's identity check and be destroyed as an orphan.
492
+ hostId: sandboxId,
493
+ driverResourceId: sandboxId,
494
+ labels,
495
+ driver: { id: "cloudflare", resourceId: sandboxId }
496
+ };
497
+ return [target];
498
+ });
499
+ } finally {
500
+ deadline.cleanup();
501
+ }
502
+ },
130
503
  metadata: {
131
504
  driverRunsIn: ["worker"],
132
505
  hostStopBehavior: "not-supported",
133
506
  hostResumeBehavior: "same-host",
134
507
  targetAccess: "relay",
135
- secretBrokering: "proxy"
508
+ secretBrokering: "native",
509
+ egressControl: "none",
510
+ persistence: sandboxDriverCatalog.cloudflare.metadata.persistence
136
511
  },
137
512
  ensureHost,
138
513
  async touch(target) {
@@ -146,9 +521,23 @@ function createCloudflareSandboxDriver(options) {
146
521
  if (status >= 400 && status !== 404) {
147
522
  throw new Error(`Cloudflare destroy failed (${status}) for ${target.sandboxId}`);
148
523
  }
524
+ },
525
+ 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"}`);
535
+ }
536
+ return { snapshotId: backupId };
149
537
  }
150
538
  };
151
539
  }
152
540
  export {
541
+ CloudflareSandboxListingUnsupportedError,
153
542
  createCloudflareSandboxDriver
154
543
  };
@@ -1,4 +1,5 @@
1
1
  import type { SandboxDriver, SandboxDriverEnsureInput } from "..";
2
+ import { type WorkspaceRuntimeControlEnv } from "../runtime-env";
2
3
  export type DaytonaSandboxLike = {
3
4
  id: string;
4
5
  state?: string;
@@ -18,9 +19,40 @@ export type DaytonaSandboxLike = {
18
19
  start: (timeout?: number) => Promise<void>;
19
20
  stop: (timeout?: number) => Promise<void>;
20
21
  delete: (timeout?: number) => Promise<void>;
22
+ updateSecrets: (secrets: Record<string, string>) => Promise<void>;
23
+ /**
24
+ * Replace the sandbox's outbound egress policy in place, without stopping it
25
+ * — the Daytona SDK's `Sandbox.updateNetworkSettings`, which drives the same
26
+ * runner-side iptables mechanism as `create`'s `networkBlockAll` /
27
+ * `networkAllowList` / `domainAllowList`. This is what makes the create-time
28
+ * policy reappliable on reuse and resume; see `applyNetworkPolicy` below.
29
+ *
30
+ * Optional because a client injected by an embedder may predate it. When it
31
+ * is absent and a restricted policy was requested, reuse is REFUSED rather
32
+ * than silently served on the creation-time policy.
33
+ */
34
+ updateNetworkSettings?: (settings: {
35
+ networkBlockAll?: boolean;
36
+ networkAllowList?: string;
37
+ domainAllowList?: string;
38
+ }) => Promise<void>;
39
+ _experimental_createSnapshot?: (name: string, timeout?: number) => Promise<void>;
21
40
  };
22
41
  export type DaytonaClientLike = {
23
42
  findByLabels?: (labels: Record<string, string>) => Promise<DaytonaSandboxLike | undefined>;
43
+ /**
44
+ * One page of sandboxes matching `labels`. Pages are 1-based; a page that
45
+ * returns fewer than `limit` items (or none) is the last one — that is the
46
+ * termination rule `list()` below relies on, and any implementation must
47
+ * honor it or the sweep loops forever.
48
+ *
49
+ * `createDefaultClient` satisfies it by returning every match on page 1 and
50
+ * nothing after: the Daytona SDK's `list()` is an `AsyncIterableIterator`
51
+ * that paginates internally, so `limit` is its per-page fetch size and NOT a
52
+ * cap on the total (see @daytona/sdk `ListSandboxesQuery.limit`). There is no
53
+ * cursor to hand back, so re-slicing per page would re-walk the whole account
54
+ * on every call.
55
+ */
24
56
  list?: (labels?: Record<string, string>, page?: number, limit?: number) => Promise<{
25
57
  items?: DaytonaSandboxLike[];
26
58
  }>;
@@ -36,23 +68,53 @@ export type DaytonaClientLike = {
36
68
  autoStopInterval?: number;
37
69
  autoDeleteInterval?: number;
38
70
  networkBlockAll?: boolean;
71
+ /** Comma-separated allowed CIDR network addresses. */
39
72
  networkAllowList?: string;
73
+ /** Comma-separated allowed domains — the name-based egress allowlist. */
74
+ domainAllowList?: string;
40
75
  }, options?: {
41
76
  timeout?: number;
42
77
  }) => Promise<DaytonaSandboxLike>;
43
78
  get: (sandboxIdOrName: string) => Promise<DaytonaSandboxLike>;
44
79
  /**
45
- * Create-or-update an org secret whose value is only substituted by
46
- * Daytona's egress proxy for requests to `hosts`
47
- * (https://www.daytona.io/docs/en/secrets/). Referenced from `create`'s
48
- * `secrets` map; inside the sandbox the env var holds only an opaque
49
- * `dtn_secret_…` placeholder, never the value.
80
+ * The org-secret service, in the SDK's own shape so `createDefaultClient`
81
+ * hands `sdk.secret` straight through. Upsert and withdrawal POLICY lives in
82
+ * this driver (`reconcileBrokeredSecrets`) rather than behind a
83
+ * `upsertSecret` client method: `create` is a conflict on an existing name,
84
+ * so a client-level "upsert" is a second implementation of the lookup every
85
+ * embedder would have to get right.
50
86
  */
51
- upsertSecret?: (params: {
87
+ secret?: DaytonaSecretServiceLike;
88
+ };
89
+ /** Identity of an org secret. The plaintext value is write-only and never returned. */
90
+ export type DaytonaSecretLike = {
91
+ id: string;
92
+ name: string;
93
+ };
94
+ export type DaytonaSecretServiceLike = {
95
+ /**
96
+ * `name` is a PARTIAL match, and pages are cursor-based; callers filter the
97
+ * page themselves and follow `nextCursor` until it is null.
98
+ */
99
+ list: (query?: {
100
+ name?: string;
101
+ cursor?: string;
102
+ limit?: number;
103
+ }) => Promise<{
104
+ items: DaytonaSecretLike[];
105
+ nextCursor?: string | null;
106
+ }>;
107
+ /** Throws `DaytonaConflictError` when a secret of that name already exists. */
108
+ create: (params: {
52
109
  name: string;
53
110
  value: string;
54
- hosts: string[];
55
- }) => Promise<void>;
111
+ hosts?: string[];
112
+ }) => Promise<DaytonaSecretLike>;
113
+ update: (secretId: string, params: {
114
+ value?: string;
115
+ hosts?: string[];
116
+ }) => Promise<unknown>;
117
+ delete: (secretId: string) => Promise<void>;
56
118
  };
57
119
  export type DaytonaSandboxDriverOptions = {
58
120
  /** Daytona API base URL. Defaults to the SDK default. */
@@ -74,11 +136,7 @@ export type DaytonaSandboxDriverOptions = {
74
136
  /** Default runner injected as WORKSPACE_RUNTIME_RUNNER. */
75
137
  runner?: string;
76
138
  /** Static control-plane config injected so the runtime verifies relay-proxied requests. */
77
- controlEnv?: {
78
- relayJwksUrl?: string;
79
- relayVerifyPem?: string;
80
- managementJwksUrl?: string;
81
- };
139
+ controlEnv?: WorkspaceRuntimeControlEnv;
82
140
  /** Dynamic runtime env that needs the acquired sandbox id or current lease. */
83
141
  env?: (input: SandboxDriverEnsureInput, sandbox: DaytonaSandboxLike) => Record<string, string> | Promise<Record<string, string>>;
84
142
  /** Auto-stop / auto-delete policy, in minutes. */
@@ -90,5 +148,16 @@ export type DaytonaSandboxDriverOptions = {
90
148
  operationTimeoutSeconds?: number;
91
149
  /** Injected for tests. */
92
150
  client?: DaytonaClientLike;
151
+ /**
152
+ * Sink for the two operator-visible events this driver has no other way to
153
+ * report: a restart it had to force on a live sandbox, and an org secret
154
+ * under this workspace's prefix that it did not mint and therefore emptied
155
+ * without mounting.
156
+ *
157
+ * Defaults to `console.warn`, deliberately unconditional — the same
158
+ * reasoning as `onEgressUnenforced` in index.ts. Pass your own to route them
159
+ * into a logger; pass `() => {}` only if you have another way to see them.
160
+ */
161
+ warn?: (message: string) => void;
93
162
  };
94
163
  export declare function createDaytonaSandboxDriver(options: DaytonaSandboxDriverOptions): SandboxDriver;