@claxedo/sandbox-manager 0.6.0 → 0.7.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.
@@ -30,16 +30,8 @@ import { Image, DaytonaNotFoundError } from "@daytona/sdk";
30
30
 
31
31
  // src/runtime-version.ts
32
32
  var DEFAULT_WORKSPACE_RUNTIME_VERSION = "0.5.2";
33
- var versionCache;
34
33
  function workspaceRuntimeVersion() {
35
- if (versionCache) return versionCache;
36
- const envVersion = process.env.WORKSPACE_RUNTIME_VERSION;
37
- if (envVersion) {
38
- versionCache = envVersion;
39
- return versionCache;
40
- }
41
- versionCache = DEFAULT_WORKSPACE_RUNTIME_VERSION;
42
- return versionCache;
34
+ return process.env.WORKSPACE_RUNTIME_VERSION || DEFAULT_WORKSPACE_RUNTIME_VERSION;
43
35
  }
44
36
 
45
37
  // src/image-name.ts
@@ -68,6 +60,207 @@ var SANDBOX_IMAGE_REPOSITORY = sandboxImageRepository();
68
60
  var SANDBOX_IMAGE = process.env.CLAXEDO_SANDBOX_IMAGE || defaultSandboxImage();
69
61
  var SNAPSHOT_NAME = process.env.CLAXEDO_SNAPSHOT_NAME || defaultSnapshotName();
70
62
 
63
+ // src/hosted-network-policy.ts
64
+ var SANDBOX_MODEL_PROVIDER_HOSTS = [
65
+ "api.anthropic.com",
66
+ "api.openai.com",
67
+ "generativelanguage.googleapis.com",
68
+ "openrouter.ai",
69
+ // Model catalog + engine config, fetched on runner startup.
70
+ "models.dev",
71
+ "opencode.ai"
72
+ ];
73
+ var SANDBOX_PACKAGE_REGISTRY_HOSTS = [
74
+ "registry.npmjs.org",
75
+ "nodejs.org",
76
+ "pypi.org",
77
+ "files.pythonhosted.org",
78
+ "crates.io",
79
+ "static.crates.io",
80
+ "index.crates.io",
81
+ "proxy.golang.org",
82
+ "sum.golang.org"
83
+ ];
84
+ var SANDBOX_BASELINE_EGRESS_HOSTS = [
85
+ ...SANDBOX_MODEL_PROVIDER_HOSTS,
86
+ ...SANDBOX_PACKAGE_REGISTRY_HOSTS
87
+ ];
88
+
89
+ // src/driver-catalog.ts
90
+ var sandboxDriverCatalog = {
91
+ exe: {
92
+ id: "exe",
93
+ label: "exe.dev",
94
+ credentialFields: [{ key: "api_token", label: "API Token", secret: true }],
95
+ metadata: {
96
+ driverRunsIn: ["worker", "node"],
97
+ hostStopBehavior: "not-supported",
98
+ hostResumeBehavior: "same-host",
99
+ targetAccess: "relay",
100
+ secretBrokering: "none",
101
+ egressControl: "none",
102
+ persistence: {
103
+ resume: "same-sandbox",
104
+ capture: "none",
105
+ clone: true,
106
+ captureSource: "not-applicable",
107
+ retention: "provider-managed",
108
+ restoreMount: "same-resource"
109
+ }
110
+ }
111
+ },
112
+ daytona: {
113
+ id: "daytona",
114
+ label: "Daytona",
115
+ credentialFields: [{ key: "api_key", label: "API Key", secret: true }],
116
+ metadata: {
117
+ driverRunsIn: ["worker", "node"],
118
+ hostStopBehavior: "suspends-host",
119
+ hostResumeBehavior: "same-host",
120
+ targetAccess: "relay",
121
+ secretBrokering: "native",
122
+ // Daytona is the only driver that can filter egress by NAME as well as
123
+ // by address: `domainAllowList` (names) alongside `networkAllowList`
124
+ // (CIDRs), with `networkBlockAll` as the deny-all floor.
125
+ egressControl: "hosts-and-cidrs",
126
+ persistence: {
127
+ resume: "same-sandbox",
128
+ capture: "filesystem",
129
+ clone: false,
130
+ captureSource: "preserved",
131
+ retention: "provider-managed",
132
+ restoreMount: "new-resource"
133
+ }
134
+ }
135
+ },
136
+ modal: {
137
+ id: "modal",
138
+ label: "Modal",
139
+ credentialFields: [
140
+ { key: "token_id", label: "Token ID", secret: true },
141
+ { key: "token_secret", label: "Token Secret", secret: true }
142
+ ],
143
+ metadata: {
144
+ driverRunsIn: ["node"],
145
+ hostStopBehavior: "terminates-host",
146
+ hostResumeBehavior: "replacement-host",
147
+ targetAccess: "relay",
148
+ secretBrokering: "none",
149
+ // Modal can cut the network entirely (`blockNetwork`) but cannot express
150
+ // an allowlist, and its driver throws on a host policy. A blackout is
151
+ // not containment for a workspace that has to clone and reach a model.
152
+ egressControl: "none",
153
+ persistence: {
154
+ resume: "replacement-restore",
155
+ capture: "filesystem",
156
+ clone: false,
157
+ captureSource: "preserved",
158
+ retention: "provider-managed",
159
+ restoreMount: "new-resource"
160
+ }
161
+ }
162
+ },
163
+ vercel: {
164
+ id: "vercel",
165
+ label: "Vercel",
166
+ credentialFields: [
167
+ { key: "access_token", label: "Access Token", secret: true },
168
+ { key: "team_id", label: "Team ID" },
169
+ { key: "project_id", label: "Project ID" }
170
+ ],
171
+ metadata: {
172
+ driverRunsIn: ["node"],
173
+ hostStopBehavior: "terminates-host",
174
+ hostResumeBehavior: "replacement-host",
175
+ targetAccess: "relay",
176
+ secretBrokering: "native",
177
+ // Vercel's sandbox firewall takes a hostname allow list (or "deny-all"),
178
+ // never CIDRs.
179
+ egressControl: "hosts",
180
+ persistence: {
181
+ resume: "replacement-restore",
182
+ capture: "filesystem",
183
+ clone: false,
184
+ captureSource: "stopped",
185
+ retention: "explicit",
186
+ restoreMount: "new-resource"
187
+ }
188
+ }
189
+ },
190
+ cloudflare: {
191
+ id: "cloudflare",
192
+ label: "Cloudflare",
193
+ credentialFields: [
194
+ { key: "api_token", label: "API Token", secret: true },
195
+ { key: "worker_url", label: "Worker URL" }
196
+ ],
197
+ metadata: {
198
+ driverRunsIn: ["worker"],
199
+ hostStopBehavior: "not-supported",
200
+ hostResumeBehavior: "same-host",
201
+ targetAccess: "relay",
202
+ secretBrokering: "proxy",
203
+ // The Cloudflare Sandbox Worker exposes no egress filter: its broker
204
+ // (drivers/cloudflare-egress.ts) is an OPT-IN proxy the sandbox chooses
205
+ // to route brokered-credential requests through, not a boundary. The
206
+ // driver therefore drops `net` entirely — which is exactly why this
207
+ // must be declared, so the manager refuses instead of provisioning a
208
+ // sandbox the caller believes is contained.
209
+ egressControl: "none",
210
+ persistence: {
211
+ resume: "replacement-restore",
212
+ capture: "directories",
213
+ clone: false,
214
+ captureSource: "preserved",
215
+ retention: "provider-managed",
216
+ restoreMount: "copy-on-write"
217
+ }
218
+ }
219
+ },
220
+ box: {
221
+ id: "box",
222
+ label: "Box",
223
+ credentialFields: [{ key: "api_key", label: "API Key", secret: true }],
224
+ metadata: {
225
+ driverRunsIn: ["node"],
226
+ hostStopBehavior: "suspends-host",
227
+ hostResumeBehavior: "same-host",
228
+ targetAccess: "relay",
229
+ secretBrokering: "none",
230
+ egressControl: "none",
231
+ persistence: {
232
+ resume: "same-sandbox",
233
+ capture: "none",
234
+ clone: false,
235
+ captureSource: "not-applicable",
236
+ retention: "provider-managed",
237
+ restoreMount: "same-resource"
238
+ }
239
+ }
240
+ },
241
+ docker: {
242
+ id: "docker",
243
+ label: "Docker",
244
+ credentialFields: [{ key: "image", label: "Image" }],
245
+ metadata: {
246
+ driverRunsIn: ["local"],
247
+ hostStopBehavior: "terminates-host",
248
+ hostResumeBehavior: "same-host",
249
+ targetAccess: "loopback",
250
+ secretBrokering: "none",
251
+ egressControl: "none",
252
+ persistence: {
253
+ resume: "replacement-restore",
254
+ capture: "same-resource",
255
+ clone: false,
256
+ captureSource: "preserved",
257
+ retention: "provider-managed",
258
+ restoreMount: "same-resource"
259
+ }
260
+ }
261
+ }
262
+ };
263
+
71
264
  // src/drivers/box.ts
72
265
  var DEFAULT_BASE_URL = "https://ascii.dev/api/box/v1";
73
266
  var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
@@ -288,7 +481,9 @@ function createBoxSandboxDriver(options) {
288
481
  hostStopBehavior: "suspends-host",
289
482
  hostResumeBehavior: "same-host",
290
483
  targetAccess: "relay",
291
- secretBrokering: "none"
484
+ secretBrokering: "none",
485
+ egressControl: "none",
486
+ persistence: sandboxDriverCatalog.box.metadata.persistence
292
487
  },
293
488
  ensureHost,
294
489
  resumeHost,
@@ -1,4 +1,4 @@
1
- import type { SandboxDriver, SandboxDriverEnsureInput } from "..";
1
+ import type { SandboxDriver, SandboxDriverEnsureInput, SandboxListingUnsupported } from "..";
2
2
  export type CloudflareSandboxDriverOptions = {
3
3
  /** Base URL of the deployed Cloudflare Sandbox Worker (e.g. https://sbx.example.com). */
4
4
  workerUrl: string;
@@ -34,4 +34,18 @@ export type CloudflareSandboxDriverOptions = {
34
34
  /** ensure-runtime/HTTP timeout. */
35
35
  timeoutMs?: number;
36
36
  };
37
+ /**
38
+ * Thrown when this driver's sandbox Worker cannot enumerate sandboxes — one
39
+ * deployed before the `/sandboxes` registry route, or without its R2 binding.
40
+ *
41
+ * A typed signal rather than a plain Error because the manager must tell apart
42
+ * two cases that both "fail to list": a transient listing error (a real sweep
43
+ * failure, propagate it) and a standing capability gap (report
44
+ * `listingUnsupported`, same as a driver with no `list()`). Returning `[]` for
45
+ * the latter would report a clean sweep while seeing nothing.
46
+ */
47
+ export declare class CloudflareSandboxListingUnsupportedError extends Error implements SandboxListingUnsupported {
48
+ readonly listingUnsupported: true;
49
+ constructor(message: string);
50
+ }
37
51
  export declare function createCloudflareSandboxDriver(options: CloudflareSandboxDriverOptions): SandboxDriver;
@@ -1,6 +1,207 @@
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/driver-catalog.ts
31
+ 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
+ modal: {
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
+ ],
84
+ metadata: {
85
+ driverRunsIn: ["node"],
86
+ hostStopBehavior: "terminates-host",
87
+ hostResumeBehavior: "replacement-host",
88
+ targetAccess: "relay",
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.
93
+ egressControl: "none",
94
+ persistence: {
95
+ resume: "replacement-restore",
96
+ capture: "filesystem",
97
+ clone: false,
98
+ captureSource: "preserved",
99
+ retention: "provider-managed",
100
+ restoreMount: "new-resource"
101
+ }
102
+ }
103
+ },
104
+ vercel: {
105
+ 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
+ ],
112
+ metadata: {
113
+ driverRunsIn: ["node"],
114
+ hostStopBehavior: "terminates-host",
115
+ hostResumeBehavior: "replacement-host",
116
+ targetAccess: "relay",
117
+ secretBrokering: "native",
118
+ // Vercel's sandbox firewall takes a hostname allow list (or "deny-all"),
119
+ // never CIDRs.
120
+ egressControl: "hosts",
121
+ persistence: {
122
+ resume: "replacement-restore",
123
+ capture: "filesystem",
124
+ clone: false,
125
+ captureSource: "stopped",
126
+ retention: "explicit",
127
+ restoreMount: "new-resource"
128
+ }
129
+ }
130
+ },
131
+ cloudflare: {
132
+ id: "cloudflare",
133
+ label: "Cloudflare",
134
+ credentialFields: [
135
+ { key: "api_token", label: "API Token", secret: true },
136
+ { key: "worker_url", label: "Worker URL" }
137
+ ],
138
+ metadata: {
139
+ driverRunsIn: ["worker"],
140
+ hostStopBehavior: "not-supported",
141
+ hostResumeBehavior: "same-host",
142
+ 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.
150
+ egressControl: "none",
151
+ persistence: {
152
+ resume: "replacement-restore",
153
+ capture: "directories",
154
+ clone: false,
155
+ captureSource: "preserved",
156
+ retention: "provider-managed",
157
+ restoreMount: "copy-on-write"
158
+ }
159
+ }
160
+ },
161
+ box: {
162
+ id: "box",
163
+ label: "Box",
164
+ credentialFields: [{ key: "api_key", label: "API Key", secret: true }],
165
+ metadata: {
166
+ driverRunsIn: ["node"],
167
+ hostStopBehavior: "suspends-host",
168
+ hostResumeBehavior: "same-host",
169
+ targetAccess: "relay",
170
+ secretBrokering: "none",
171
+ egressControl: "none",
172
+ persistence: {
173
+ resume: "same-sandbox",
174
+ capture: "none",
175
+ clone: false,
176
+ captureSource: "not-applicable",
177
+ retention: "provider-managed",
178
+ restoreMount: "same-resource"
179
+ }
180
+ }
181
+ },
182
+ docker: {
183
+ id: "docker",
184
+ label: "Docker",
185
+ credentialFields: [{ key: "image", label: "Image" }],
186
+ metadata: {
187
+ driverRunsIn: ["local"],
188
+ hostStopBehavior: "terminates-host",
189
+ hostResumeBehavior: "same-host",
190
+ targetAccess: "loopback",
191
+ secretBrokering: "none",
192
+ egressControl: "none",
193
+ persistence: {
194
+ resume: "replacement-restore",
195
+ capture: "same-resource",
196
+ clone: false,
197
+ captureSource: "preserved",
198
+ retention: "provider-managed",
199
+ restoreMount: "same-resource"
200
+ }
201
+ }
202
+ }
203
+ };
204
+
4
205
  // src/runtime-env.ts
5
206
  function workspaceRuntimeTargetEnv(input) {
6
207
  return {
@@ -24,12 +225,24 @@ function workspaceRuntimeSourceEnv(input) {
24
225
  var DEFAULT_WORKSPACE_DIR = "/workspace";
25
226
  var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
26
227
  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
+ }
27
233
  function cleanUrl(input) {
28
234
  return input.replace(/\/+$/, "");
29
235
  }
30
236
  function sandboxIdFor(workspaceId) {
31
237
  return `claxedo-${workspaceId}`;
32
238
  }
239
+ var CloudflareSandboxListingUnsupportedError = class extends Error {
240
+ listingUnsupported = true;
241
+ constructor(message) {
242
+ super(message);
243
+ this.name = "CloudflareSandboxListingUnsupportedError";
244
+ }
245
+ };
33
246
  function createCloudflareSandboxDriver(options) {
34
247
  const doFetch = options.fetch ?? fetch;
35
248
  const runtimePort = options.runtimePort ?? DEFAULT_WORKSPACE_RUNTIME_PORT;
@@ -65,6 +278,8 @@ function createCloudflareSandboxDriver(options) {
65
278
  host: "0.0.0.0"
66
279
  }),
67
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,
68
283
  ...input.env,
69
284
  ...await options.env?.(input, { id: hostId })
70
285
  };
@@ -99,6 +314,11 @@ function createCloudflareSandboxDriver(options) {
99
314
  env: await bootEnv(input, hostId),
100
315
  port: runtimePort,
101
316
  command: runtimeCommand,
317
+ // Sent so the Worker can record them in its sandbox registry, which is
318
+ // what makes this driver's `list()` (and therefore GC) possible at all.
319
+ // Not container env — labels are control-plane metadata.
320
+ labels: input.labels,
321
+ ...input.bootSource?.kind === "driver-snapshot" ? { restore: { backupId: input.bootSource.snapshotId, directories: captureDirectories(workspaceDirectory(input)) } } : {},
102
322
  ...egress ? { egress } : {}
103
323
  }
104
324
  ).catch((error) => {
@@ -121,18 +341,84 @@ function createCloudflareSandboxDriver(options) {
121
341
  id: "cloudflare",
122
342
  resourceId: sandboxId
123
343
  },
124
- labels: input.labels
344
+ labels: {
345
+ ...input.labels,
346
+ [WORKSPACE_DIRECTORY_LABEL]: workspaceDirectory(input)
347
+ }
125
348
  };
126
349
  return targetOut;
127
350
  }
128
351
  return {
129
352
  id: "cloudflare",
353
+ // Provider-state enumeration, served by the sandbox Worker's registry
354
+ // (`GET /sandboxes`) rather than by Cloudflare's own APIs.
355
+ //
356
+ // Why a registry is REQUIRED here, checked against the installed SDK
357
+ // (@cloudflare/sandbox 0.8.9) rather than assumed:
358
+ //
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.
362
+ // 2. The account-level REST list-objects endpoint returns
363
+ // `{ id: <hex>, hasStoredData }` and nothing else. `idFromName` is
364
+ // one-way, so a hex id cannot be mapped back to `claxedo-<workspaceId>`
365
+ // — no name means no labels, no `app` ownership check, and no safe
366
+ // destroy. A sweep over hex ids could only guess what it was deleting.
367
+ // 3. It would also need account-level CF credentials this driver
368
+ // deliberately does not hold; it authenticates to one user-deployed
369
+ // Worker with that Worker's own bearer token.
370
+ //
371
+ // So the Worker records each sandbox on `ensure-runtime` and drops it on
372
+ // destroy — the pattern Cloudflare recommends for precisely this reason.
373
+ //
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.
380
+ 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"}`);
394
+ }
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
+ },
130
414
  metadata: {
131
415
  driverRunsIn: ["worker"],
132
416
  hostStopBehavior: "not-supported",
133
417
  hostResumeBehavior: "same-host",
134
418
  targetAccess: "relay",
135
- secretBrokering: "proxy"
419
+ secretBrokering: "proxy",
420
+ egressControl: "none",
421
+ persistence: sandboxDriverCatalog.cloudflare.metadata.persistence
136
422
  },
137
423
  ensureHost,
138
424
  async touch(target) {
@@ -146,9 +432,22 @@ function createCloudflareSandboxDriver(options) {
146
432
  if (status >= 400 && status !== 404) {
147
433
  throw new Error(`Cloudflare destroy failed (${status}) for ${target.sandboxId}`);
148
434
  }
435
+ },
436
+ 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"}`);
445
+ }
446
+ return { snapshotId: data.backupId };
149
447
  }
150
448
  };
151
449
  }
152
450
  export {
451
+ CloudflareSandboxListingUnsupportedError,
153
452
  createCloudflareSandboxDriver
154
453
  };
@@ -18,9 +18,39 @@ export type DaytonaSandboxLike = {
18
18
  start: (timeout?: number) => Promise<void>;
19
19
  stop: (timeout?: number) => Promise<void>;
20
20
  delete: (timeout?: number) => Promise<void>;
21
+ /**
22
+ * Replace the sandbox's outbound egress policy in place, without stopping it
23
+ * — the Daytona SDK's `Sandbox.updateNetworkSettings`, which drives the same
24
+ * runner-side iptables mechanism as `create`'s `networkBlockAll` /
25
+ * `networkAllowList` / `domainAllowList`. This is what makes the create-time
26
+ * policy reappliable on reuse and resume; see `applyNetworkPolicy` below.
27
+ *
28
+ * Optional because a client injected by an embedder may predate it. When it
29
+ * is absent and a restricted policy was requested, reuse is REFUSED rather
30
+ * than silently served on the creation-time policy.
31
+ */
32
+ updateNetworkSettings?: (settings: {
33
+ networkBlockAll?: boolean;
34
+ networkAllowList?: string;
35
+ domainAllowList?: string;
36
+ }) => Promise<void>;
37
+ _experimental_createSnapshot?: (name: string, timeout?: number) => Promise<void>;
21
38
  };
22
39
  export type DaytonaClientLike = {
23
40
  findByLabels?: (labels: Record<string, string>) => Promise<DaytonaSandboxLike | undefined>;
41
+ /**
42
+ * One page of sandboxes matching `labels`. Pages are 1-based; a page that
43
+ * returns fewer than `limit` items (or none) is the last one — that is the
44
+ * termination rule `list()` below relies on, and any implementation must
45
+ * honor it or the sweep loops forever.
46
+ *
47
+ * `createDefaultClient` satisfies it by returning every match on page 1 and
48
+ * nothing after: the Daytona SDK's `list()` is an `AsyncIterableIterator`
49
+ * that paginates internally, so `limit` is its per-page fetch size and NOT a
50
+ * cap on the total (see @daytona/sdk `ListSandboxesQuery.limit`). There is no
51
+ * cursor to hand back, so re-slicing per page would re-walk the whole account
52
+ * on every call.
53
+ */
24
54
  list?: (labels?: Record<string, string>, page?: number, limit?: number) => Promise<{
25
55
  items?: DaytonaSandboxLike[];
26
56
  }>;
@@ -36,7 +66,10 @@ export type DaytonaClientLike = {
36
66
  autoStopInterval?: number;
37
67
  autoDeleteInterval?: number;
38
68
  networkBlockAll?: boolean;
69
+ /** Comma-separated allowed CIDR network addresses. */
39
70
  networkAllowList?: string;
71
+ /** Comma-separated allowed domains — the name-based egress allowlist. */
72
+ domainAllowList?: string;
40
73
  }, options?: {
41
74
  timeout?: number;
42
75
  }) => Promise<DaytonaSandboxLike>;