@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.
- package/README.md +50 -1
- package/dist/checkpoint-manager.d.ts +47 -0
- package/dist/checkpoint-manager.mjs +116 -0
- package/dist/daytona-allow-list.d.ts +11 -0
- package/dist/daytona-allow-list.mjs +5 -1
- package/dist/driver-catalog.d.ts +9 -6
- package/dist/driver-catalog.mjs +150 -17
- package/dist/drivers/box.mjs +205 -10
- package/dist/drivers/cloudflare.d.ts +15 -1
- package/dist/drivers/cloudflare.mjs +301 -2
- package/dist/drivers/daytona.d.ts +33 -0
- package/dist/drivers/daytona.mjs +310 -2
- package/dist/drivers/docker.mjs +205 -10
- package/dist/drivers/exe.d.ts +26 -0
- package/dist/drivers/exe.mjs +469 -0
- package/dist/drivers/fetch-bridge.mjs +10 -1
- package/dist/drivers/modal.mjs +205 -10
- package/dist/drivers/vercel.d.ts +23 -4
- package/dist/drivers/vercel.mjs +225 -19
- package/dist/hosted-network-policy.d.ts +87 -0
- package/dist/hosted-network-policy.mjs +61 -0
- package/dist/image.d.ts +16 -1
- package/dist/image.mjs +14 -21
- package/dist/index.d.ts +220 -1
- package/dist/index.mjs +341 -20
- package/dist/lease-policy.d.ts +1 -2
- package/dist/lease-policy.mjs +203 -54
- package/dist/lease-types.d.ts +6 -1
- package/dist/runtime-version.mjs +1 -9
- package/dist/stores/memory.mjs +37 -2
- package/package.json +16 -2
package/dist/drivers/box.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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>;
|