@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.
- package/README.md +60 -15
- 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 +5 -52
- package/dist/driver-catalog.mjs +263 -121
- package/dist/drivers/box.mjs +309 -39
- package/dist/drivers/cloudflare.d.ts +18 -7
- package/dist/drivers/cloudflare.mjs +423 -34
- package/dist/drivers/daytona.d.ts +82 -13
- package/dist/drivers/daytona.mjs +495 -45
- package/dist/drivers/docker.mjs +284 -38
- package/dist/drivers/exe.d.ts +26 -0
- package/dist/drivers/exe.mjs +492 -0
- package/dist/drivers/fetch-bridge.mjs +29 -5
- package/dist/drivers/modal.d.ts +2 -5
- package/dist/drivers/modal.mjs +296 -46
- package/dist/drivers/transient-error.d.ts +11 -0
- package/dist/drivers/vercel.d.ts +27 -11
- package/dist/drivers/vercel.mjs +339 -59
- package/dist/hosted-network-policy.d.ts +87 -0
- package/dist/hosted-network-policy.mjs +61 -0
- package/dist/image-name.mjs +34 -0
- package/dist/image.d.ts +18 -3
- package/dist/image.mjs +47 -32
- package/dist/index.d.ts +281 -17
- package/dist/index.mjs +393 -26
- package/dist/json.d.ts +13 -0
- package/dist/lease-policy.d.ts +6 -3
- package/dist/lease-policy.mjs +213 -54
- package/dist/lease-types.d.ts +17 -3
- package/dist/runtime-env.d.ts +12 -1
- package/dist/runtime-env.mjs +14 -0
- package/dist/runtime-version.mjs +1 -9
- package/dist/service-url-exposure.d.ts +1 -2
- package/dist/service-url-exposure.mjs +1 -17
- package/dist/stores/memory.mjs +39 -4
- package/package.json +67 -51
- package/dist/drivers/cloudflare-egress.d.ts +0 -40
- 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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
if (status
|
|
112
|
-
|
|
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:
|
|
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:
|
|
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: "
|
|
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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* (
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
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
|
-
|
|
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
|
|
55
|
-
}) => Promise<
|
|
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;
|