@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
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,217 @@
|
|
|
1
1
|
// src/constants.ts
|
|
2
2
|
var DEFAULT_WORKSPACE_RUNTIME_PORT = 2593;
|
|
3
3
|
|
|
4
|
+
// src/checkpoint-manager.ts
|
|
5
|
+
async function captureSandboxCheckpoint(input) {
|
|
6
|
+
const lease = await requireLease(input.leaseStore, input.workspaceId);
|
|
7
|
+
if (lease.status !== "ready") throw new Error("workspace_checkpoint_lease_not_ready");
|
|
8
|
+
const persistence = lease.persistence;
|
|
9
|
+
if (!persistence || persistence.capture === "none") throw new Error("workspace_checkpoint_unsupported");
|
|
10
|
+
const target = await input.target(input.workspaceId);
|
|
11
|
+
if (target.status !== "ready") throw new Error(target.reason);
|
|
12
|
+
await input.request.runtime.freeze(input.request.policy ?? "drain");
|
|
13
|
+
let capturedSource = false;
|
|
14
|
+
try {
|
|
15
|
+
await input.request.runtime.flush();
|
|
16
|
+
await input.request.runtime.scrub();
|
|
17
|
+
const captured = persistence.capture === "same-resource" ? { ok: true, snapshotId: target.driverResourceId ?? target.sandboxId } : await input.snapshot(input.workspaceId);
|
|
18
|
+
if (!captured.ok) throw new Error(captured.reason);
|
|
19
|
+
capturedSource = true;
|
|
20
|
+
const capturedAt = (input.now ?? Date.now)();
|
|
21
|
+
const checkpoint = {
|
|
22
|
+
id: input.checkpointId?.() ?? `cp_${lease.epoch}_${crypto.randomUUID()}`,
|
|
23
|
+
providerReference: captured.snapshotId,
|
|
24
|
+
sourceEpoch: lease.epoch,
|
|
25
|
+
capturedAt,
|
|
26
|
+
metadata: {
|
|
27
|
+
scope: persistence.capture,
|
|
28
|
+
sourceBehavior: requireCaptureSource(persistence.captureSource),
|
|
29
|
+
...input.request.retentionExpiresAt === void 0 ? {} : { retentionExpiresAt: input.request.retentionExpiresAt },
|
|
30
|
+
restoreMount: requireRestoreMount(persistence.restoreMount)
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const updated = await input.leaseStore.update(input.workspaceId, lease.epoch, {
|
|
34
|
+
checkpoint,
|
|
35
|
+
restore: null,
|
|
36
|
+
...persistence.captureSource === "stopped" || persistence.captureSource === "deleted" ? { status: "stopped" } : {}
|
|
37
|
+
});
|
|
38
|
+
if (!updated) throw new Error("workspace_checkpoint_epoch_fenced");
|
|
39
|
+
return { status: "ready", lease: updated, checkpoint };
|
|
40
|
+
} finally {
|
|
41
|
+
if (!capturedSource || persistence.captureSource === "preserved") {
|
|
42
|
+
await input.request.runtime.resume();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function restoreSandboxCheckpoint(input) {
|
|
47
|
+
const lease = await requireLease(input.leaseStore, input.workspaceId);
|
|
48
|
+
const checkpoint = lease.checkpoint;
|
|
49
|
+
if (!checkpoint || input.request.checkpointId && checkpoint.id !== input.request.checkpointId) {
|
|
50
|
+
throw new Error("workspace_checkpoint_not_found");
|
|
51
|
+
}
|
|
52
|
+
if (lease.restore?.state === "ready" && lease.restore.checkpointId === checkpoint.id) {
|
|
53
|
+
return { status: "ready", lease, checkpoint };
|
|
54
|
+
}
|
|
55
|
+
const requestedAt = lease.restore?.checkpointId === checkpoint.id ? lease.restore.requestedAt : (input.now ?? Date.now)();
|
|
56
|
+
const restoring = lease.restore?.checkpointId === checkpoint.id && lease.restore.state !== "pending" ? lease.restore : {
|
|
57
|
+
checkpointId: checkpoint.id,
|
|
58
|
+
sourceEpoch: checkpoint.sourceEpoch,
|
|
59
|
+
state: "restoring",
|
|
60
|
+
requestedAt,
|
|
61
|
+
startedAt: (input.now ?? Date.now)()
|
|
62
|
+
};
|
|
63
|
+
const staged = lease.status === "ready" && lease.epoch === checkpoint.sourceEpoch ? await input.leaseStore.update(input.workspaceId, lease.epoch, { status: "stopped", restore: restoring }) : await input.leaseStore.update(input.workspaceId, lease.epoch, { restore: restoring });
|
|
64
|
+
if (!staged) throw new Error("workspace_restore_epoch_fenced");
|
|
65
|
+
const result = await input.ensure(input.workspaceId, {
|
|
66
|
+
homeRegion: staged.homeRegion,
|
|
67
|
+
...input.request.ensure,
|
|
68
|
+
bootSource: checkpoint.metadata.restoreMount === "same-resource" ? { kind: "default" } : { kind: "driver-snapshot", snapshotId: checkpoint.providerReference }
|
|
69
|
+
});
|
|
70
|
+
const current = await requireLease(input.leaseStore, input.workspaceId);
|
|
71
|
+
if (result.status === "provisioning") {
|
|
72
|
+
return { status: "provisioning", lease: current, checkpoint, retryAfterMs: result.retryAfterMs };
|
|
73
|
+
}
|
|
74
|
+
if (result.status !== "ready") {
|
|
75
|
+
const failedAt = (input.now ?? Date.now)();
|
|
76
|
+
await input.leaseStore.update(input.workspaceId, current.epoch, {
|
|
77
|
+
restore: {
|
|
78
|
+
checkpointId: checkpoint.id,
|
|
79
|
+
sourceEpoch: checkpoint.sourceEpoch,
|
|
80
|
+
state: "failed",
|
|
81
|
+
requestedAt,
|
|
82
|
+
startedAt: "startedAt" in restoring ? restoring.startedAt : void 0,
|
|
83
|
+
failedAt,
|
|
84
|
+
error: result.error ?? "workspace_restore_unavailable"
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
throw new Error(result.error ?? "workspace_restore_unavailable");
|
|
88
|
+
}
|
|
89
|
+
await input.request.runtime.reconcile({ epoch: result.epoch, checkpointId: checkpoint.id });
|
|
90
|
+
const ready = await input.leaseStore.update(input.workspaceId, result.epoch, {
|
|
91
|
+
restore: {
|
|
92
|
+
checkpointId: checkpoint.id,
|
|
93
|
+
sourceEpoch: checkpoint.sourceEpoch,
|
|
94
|
+
state: "ready",
|
|
95
|
+
requestedAt,
|
|
96
|
+
startedAt: "startedAt" in restoring && restoring.startedAt !== void 0 ? restoring.startedAt : (input.now ?? Date.now)(),
|
|
97
|
+
completedAt: (input.now ?? Date.now)()
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
if (!ready) throw new Error("workspace_restore_epoch_fenced");
|
|
101
|
+
return { status: "ready", lease: ready, checkpoint };
|
|
102
|
+
}
|
|
103
|
+
async function requireLease(store, workspaceId) {
|
|
104
|
+
const lease = await store.get(workspaceId);
|
|
105
|
+
if (!lease) throw new Error("workspace_checkpoint_lease_missing");
|
|
106
|
+
return lease;
|
|
107
|
+
}
|
|
108
|
+
function requireCaptureSource(value) {
|
|
109
|
+
if (value === "preserved" || value === "stopped" || value === "deleted") return value;
|
|
110
|
+
throw new Error("workspace_checkpoint_capabilities_invalid");
|
|
111
|
+
}
|
|
112
|
+
function requireRestoreMount(value) {
|
|
113
|
+
if (value === "same-resource" || value === "copy-on-write" || value === "new-resource") return value;
|
|
114
|
+
throw new Error("workspace_checkpoint_capabilities_invalid");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/hosted-network-policy.ts
|
|
118
|
+
var SANDBOX_MODEL_PROVIDER_HOSTS = [
|
|
119
|
+
"api.anthropic.com",
|
|
120
|
+
"api.openai.com",
|
|
121
|
+
"generativelanguage.googleapis.com",
|
|
122
|
+
"openrouter.ai",
|
|
123
|
+
// Model catalog + engine config, fetched on runner startup.
|
|
124
|
+
"models.dev",
|
|
125
|
+
"opencode.ai"
|
|
126
|
+
];
|
|
127
|
+
var SANDBOX_PACKAGE_REGISTRY_HOSTS = [
|
|
128
|
+
"registry.npmjs.org",
|
|
129
|
+
"nodejs.org",
|
|
130
|
+
"pypi.org",
|
|
131
|
+
"files.pythonhosted.org",
|
|
132
|
+
"crates.io",
|
|
133
|
+
"static.crates.io",
|
|
134
|
+
"index.crates.io",
|
|
135
|
+
"proxy.golang.org",
|
|
136
|
+
"sum.golang.org"
|
|
137
|
+
];
|
|
138
|
+
var SANDBOX_BASELINE_EGRESS_HOSTS = [
|
|
139
|
+
...SANDBOX_MODEL_PROVIDER_HOSTS,
|
|
140
|
+
...SANDBOX_PACKAGE_REGISTRY_HOSTS
|
|
141
|
+
];
|
|
142
|
+
function hostOf(value) {
|
|
143
|
+
const raw = value?.trim();
|
|
144
|
+
if (!raw) return void 0;
|
|
145
|
+
try {
|
|
146
|
+
const host = new URL(raw).hostname.trim().toLowerCase();
|
|
147
|
+
return host ? host : void 0;
|
|
148
|
+
} catch {
|
|
149
|
+
return void 0;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function sandboxSourceHost(source) {
|
|
153
|
+
if (!source || source.kind !== "git") return void 0;
|
|
154
|
+
return hostOf(source.repoUrl);
|
|
155
|
+
}
|
|
156
|
+
function hostedSandboxNetworkPolicy(input) {
|
|
157
|
+
const hosts = /* @__PURE__ */ new Set();
|
|
158
|
+
for (const entry of input.controlPlane) {
|
|
159
|
+
const host = hostOf(entry) ?? entry?.trim().toLowerCase();
|
|
160
|
+
if (host) hosts.add(host);
|
|
161
|
+
}
|
|
162
|
+
const sourceHost = sandboxSourceHost(input.source);
|
|
163
|
+
if (sourceHost) hosts.add(sourceHost);
|
|
164
|
+
for (const host of SANDBOX_BASELINE_EGRESS_HOSTS) hosts.add(host);
|
|
165
|
+
for (const host of input.extraHosts ?? []) {
|
|
166
|
+
const trimmed = host.trim().toLowerCase();
|
|
167
|
+
if (trimmed) hosts.add(trimmed);
|
|
168
|
+
}
|
|
169
|
+
return { mode: "restricted", hosts: [...hosts] };
|
|
170
|
+
}
|
|
171
|
+
|
|
4
172
|
// src/index.ts
|
|
173
|
+
function validateSandboxPersistenceCapabilities(input) {
|
|
174
|
+
if (input.resume === "replacement-restore" && input.capture === "none") {
|
|
175
|
+
return { valid: false, reason: "replacement restore requires a capture source" };
|
|
176
|
+
}
|
|
177
|
+
if (input.capture === "none" && input.captureSource !== "not-applicable") {
|
|
178
|
+
return { valid: false, reason: "capture source behavior requires capture support" };
|
|
179
|
+
}
|
|
180
|
+
if (input.capture !== "none" && input.captureSource === "not-applicable") {
|
|
181
|
+
return { valid: false, reason: "capture support requires source behavior" };
|
|
182
|
+
}
|
|
183
|
+
if (input.capture === "same-resource" && input.restoreMount !== "same-resource") {
|
|
184
|
+
return { valid: false, reason: "same-resource capture requires same-resource restore" };
|
|
185
|
+
}
|
|
186
|
+
if (input.restoreMount === "copy-on-write" && input.capture !== "directories") {
|
|
187
|
+
return { valid: false, reason: "copy-on-write restore requires directory capture" };
|
|
188
|
+
}
|
|
189
|
+
return { valid: true };
|
|
190
|
+
}
|
|
191
|
+
function sandboxEgressDisposition(control, net) {
|
|
192
|
+
if (!net || net.mode === "allow-all") return { action: "enforce" };
|
|
193
|
+
if (control === "none") return { action: "withhold", reason: "sandbox_egress_uncontained" };
|
|
194
|
+
const hosts = net.hosts?.length ?? 0;
|
|
195
|
+
const cidrs = net.cidrs?.length ?? 0;
|
|
196
|
+
if (hosts === 0 && cidrs === 0) return { action: "enforce" };
|
|
197
|
+
if (hosts > 0) return { action: "enforce" };
|
|
198
|
+
return control === "hosts-and-cidrs" ? { action: "enforce" } : { action: "refuse", reason: "sandbox_egress_policy_unenforceable" };
|
|
199
|
+
}
|
|
200
|
+
function egressUnenforcedMessage(input) {
|
|
201
|
+
const scope = input.workspaceId ? `workspace ${input.workspaceId}` : "every sandbox it provisions";
|
|
202
|
+
const withheld = input.requested ? ` The requested allowlist (${(input.requested.hosts ?? []).length} host(s), ${(input.requested.cidrs ?? []).length} cidr(s)) was withheld, not applied.` : "";
|
|
203
|
+
return `[sandbox-manager] SANDBOX EGRESS IS UNRESTRICTED: driver "${input.driver}" declares egressControl: "none", so ${scope} can reach ANY host on the internet, including attacker-controlled buckets \u2014 agent-authored code inside the sandbox has an unmonitored exfiltration path.${withheld} Select a driver that can enforce egress (daytona, vercel) to close it. See public-docs/sandbox-egress.md.`;
|
|
204
|
+
}
|
|
205
|
+
var compositionEgressWarnings = /* @__PURE__ */ new Set();
|
|
206
|
+
function defaultEgressUnenforcedSink(event) {
|
|
207
|
+
console.warn(event.message);
|
|
208
|
+
}
|
|
209
|
+
function brokeredSecretPlaceholder(name) {
|
|
210
|
+
return `claxedo-broker:${name}`;
|
|
211
|
+
}
|
|
212
|
+
function brokeredPlaceholderEnv(secrets) {
|
|
213
|
+
return Object.fromEntries((secrets ?? []).map((secret) => [secret.name, brokeredSecretPlaceholder(secret.name)]));
|
|
214
|
+
}
|
|
5
215
|
function applySandboxLeasePatch(current, patch, updatedAt) {
|
|
6
216
|
return {
|
|
7
217
|
...current,
|
|
@@ -16,14 +226,32 @@ function applySandboxLeasePatch(current, patch, updatedAt) {
|
|
|
16
226
|
...patch.labels === void 0 ? {} : { labels: patch.labels },
|
|
17
227
|
...patch.nextRetryAt === void 0 ? {} : { nextRetryAt: patch.nextRetryAt ?? void 0 },
|
|
18
228
|
...patch.lastError === void 0 ? {} : { lastError: patch.lastError ?? void 0 },
|
|
229
|
+
...patch.checkpoint === void 0 ? {} : { checkpoint: patch.checkpoint ?? void 0 },
|
|
230
|
+
...patch.persistence === void 0 ? {} : { persistence: patch.persistence ?? void 0 },
|
|
231
|
+
...patch.restore === void 0 ? {} : { restore: patch.restore ?? void 0 },
|
|
19
232
|
updatedAt
|
|
20
233
|
};
|
|
21
234
|
}
|
|
235
|
+
function sandboxNetworkPolicyWithBrokeredHosts(policy, secrets) {
|
|
236
|
+
if (policy.mode !== "restricted" || !secrets?.length) return policy;
|
|
237
|
+
const hosts = [.../* @__PURE__ */ new Set([
|
|
238
|
+
...policy.hosts ?? [],
|
|
239
|
+
...secrets.flatMap((secret) => secret.hosts)
|
|
240
|
+
])].toSorted();
|
|
241
|
+
return {
|
|
242
|
+
...policy,
|
|
243
|
+
...hosts.length ? { hosts } : {}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function isSandboxListingUnsupported(err) {
|
|
247
|
+
return !!err && typeof err === "object" && err.listingUnsupported === true;
|
|
248
|
+
}
|
|
22
249
|
var DEFAULT_APP_LABEL = "claxedo";
|
|
23
250
|
function sandboxId(driver, workspaceId) {
|
|
24
251
|
return `${driver}-${workspaceId}`;
|
|
25
252
|
}
|
|
26
253
|
function ensureHostInput(input) {
|
|
254
|
+
const egress = sandboxEgressDisposition(input.driver.metadata.egressControl, input.managerInput?.net);
|
|
27
255
|
const labels = {
|
|
28
256
|
app: input.appLabel,
|
|
29
257
|
workspaceId: input.workspaceId,
|
|
@@ -37,26 +265,88 @@ function ensureHostInput(input) {
|
|
|
37
265
|
homeRegion: input.homeRegion,
|
|
38
266
|
epoch: input.lease.epoch,
|
|
39
267
|
labels,
|
|
40
|
-
bootSource: input.managerInput?.bootSource ?? { kind: "default" },
|
|
268
|
+
bootSource: input.managerInput?.bootSource ?? (input.lease.checkpoint && input.lease.status !== "ready" && input.driver.metadata.persistence.resume === "replacement-restore" ? { kind: "driver-snapshot", snapshotId: input.lease.checkpoint.providerReference } : { kind: "default" }),
|
|
41
269
|
workspaceRoot: input.managerInput?.workspaceRoot ?? "/workspace",
|
|
42
270
|
runtimeCwd: input.managerInput?.runtimeCwd,
|
|
43
271
|
workspaceRuntimePort: input.managerInput?.workspaceRuntimePort ?? DEFAULT_WORKSPACE_RUNTIME_PORT,
|
|
44
272
|
env: input.managerInput?.env ?? {},
|
|
45
273
|
// Brokered secrets ride their own channel — NEVER merged into labels or env.
|
|
46
|
-
|
|
274
|
+
// Presence of the key, not its length, is the signal: `[]` is the caller
|
|
275
|
+
// withdrawing every brokered secret, and dropping it would reach the driver
|
|
276
|
+
// as "preserve what you have".
|
|
277
|
+
...input.managerInput?.secrets ? { secrets: input.managerInput.secrets } : {},
|
|
47
278
|
source: input.managerInput?.source,
|
|
48
279
|
exposure: input.managerInput?.exposure ?? (input.driver.metadata.targetAccess === "loopback" ? { kind: "loopback" } : { kind: "relay" }),
|
|
49
|
-
|
|
280
|
+
// Withheld, never downgraded: `undefined` here is the same shape a caller
|
|
281
|
+
// that asked for no containment produces, so the throwing drivers take
|
|
282
|
+
// their allow-all path and their throws stay intact for anyone who hands
|
|
283
|
+
// them a policy directly.
|
|
284
|
+
...egress.action === "enforce" && input.managerInput?.net ? { net: sandboxNetworkPolicyWithBrokeredHosts(input.managerInput.net, input.managerInput.secrets) } : {},
|
|
50
285
|
snapshot: input.managerInput?.snapshot
|
|
51
286
|
};
|
|
52
287
|
}
|
|
53
288
|
function createSandboxManager(options) {
|
|
289
|
+
const persistence = validateSandboxPersistenceCapabilities(options.driver.metadata.persistence);
|
|
290
|
+
if (!persistence.valid) throw new Error(`Invalid sandbox persistence capabilities: ${persistence.reason}`);
|
|
54
291
|
const staleAfterMs = options.staleAfterMs ?? 6e4;
|
|
55
292
|
const retryAfterMs = options.retryAfterMs ?? 2e3;
|
|
56
293
|
const now = options.now ?? Date.now;
|
|
57
294
|
const retryDelayMs = options.retryDelayMs ?? ((retryCount) => Math.min(6e4, 1e3 * 2 ** Math.max(0, retryCount - 1)));
|
|
58
295
|
const maxRetryCount = options.maxRetryCount ?? Number.POSITIVE_INFINITY;
|
|
59
296
|
const retryCapCooldownMs = options.retryCapCooldownMs ?? 10 * 6e4;
|
|
297
|
+
const egressControl = options.driver.metadata.egressControl;
|
|
298
|
+
const onEgressUnenforced = options.onEgressUnenforced ?? defaultEgressUnenforcedSink;
|
|
299
|
+
const lifecycleOperations = /* @__PURE__ */ new Map();
|
|
300
|
+
function reportEgressUnenforced(input) {
|
|
301
|
+
onEgressUnenforced({
|
|
302
|
+
phase: "ensure",
|
|
303
|
+
reason: "sandbox_egress_uncontained",
|
|
304
|
+
driver: options.driver.id,
|
|
305
|
+
egressControl,
|
|
306
|
+
message: egressUnenforcedMessage({
|
|
307
|
+
phase: "ensure",
|
|
308
|
+
driver: options.driver.id,
|
|
309
|
+
workspaceId: input.workspaceId,
|
|
310
|
+
requested: input.requested
|
|
311
|
+
}),
|
|
312
|
+
workspaceId: input.workspaceId,
|
|
313
|
+
requested: input.requested
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (egressControl === "none") {
|
|
317
|
+
const key = `${options.driver.id}|${egressControl}`;
|
|
318
|
+
if (!compositionEgressWarnings.has(key)) {
|
|
319
|
+
compositionEgressWarnings.add(key);
|
|
320
|
+
onEgressUnenforced({
|
|
321
|
+
phase: "composition",
|
|
322
|
+
reason: "sandbox_egress_uncontained",
|
|
323
|
+
driver: options.driver.id,
|
|
324
|
+
egressControl,
|
|
325
|
+
message: egressUnenforcedMessage({ phase: "composition", driver: options.driver.id })
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function lifecycleQueue(current) {
|
|
330
|
+
return current?.promise.catch(() => void 0) ?? Promise.resolve();
|
|
331
|
+
}
|
|
332
|
+
function lifecycleSettle(workspaceId, entry, operation) {
|
|
333
|
+
lifecycleOperations.set(workspaceId, entry);
|
|
334
|
+
return operation.finally(() => {
|
|
335
|
+
if (lifecycleOperations.get(workspaceId) === entry) lifecycleOperations.delete(workspaceId);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
function checkpointLifecycle(workspaceId, kind, run) {
|
|
339
|
+
const current = lifecycleOperations.get(workspaceId);
|
|
340
|
+
if (current?.kind === kind) return current.promise;
|
|
341
|
+
const operation = lifecycleQueue(current).then(run);
|
|
342
|
+
return lifecycleSettle(workspaceId, { kind, promise: operation }, operation);
|
|
343
|
+
}
|
|
344
|
+
function mutationLifecycle(workspaceId, kind, run) {
|
|
345
|
+
const current = lifecycleOperations.get(workspaceId);
|
|
346
|
+
if (current?.kind === kind) return current.promise;
|
|
347
|
+
const operation = lifecycleQueue(current).then(run);
|
|
348
|
+
return lifecycleSettle(workspaceId, { kind, promise: operation }, operation);
|
|
349
|
+
}
|
|
60
350
|
async function leaseTarget(lease) {
|
|
61
351
|
if (lease.status !== "ready" || !lease.sandboxId || !lease.url || !lease.hostId) {
|
|
62
352
|
return { status: "unavailable", reason: "runtime_lease_not_ready" };
|
|
@@ -77,6 +367,12 @@ function createSandboxManager(options) {
|
|
|
77
367
|
homeRegion: lease.homeRegion
|
|
78
368
|
};
|
|
79
369
|
}
|
|
370
|
+
function leaseBootMode(lease) {
|
|
371
|
+
const restoring = Boolean(lease.checkpoint) && lease.status !== "ready" && options.driver.metadata.persistence.resume === "replacement-restore";
|
|
372
|
+
if (restoring) return "restore";
|
|
373
|
+
if (lease.sandboxId && options.driver.resumeHost) return "resume";
|
|
374
|
+
return "cold-start";
|
|
375
|
+
}
|
|
80
376
|
async function provision(workspaceId, lease, homeRegion, managerInput) {
|
|
81
377
|
try {
|
|
82
378
|
const ensure = ensureHostInput({
|
|
@@ -95,7 +391,8 @@ function createSandboxManager(options) {
|
|
|
95
391
|
homeRegion
|
|
96
392
|
};
|
|
97
393
|
}
|
|
98
|
-
const
|
|
394
|
+
const resuming = Boolean(lease.sandboxId && ensure.bootSource?.kind === "default" && options.driver.resumeHost);
|
|
395
|
+
const target = resuming ? await options.driver.resumeHost({ lease, ensure }) : await options.driver.ensureHost(ensure);
|
|
99
396
|
if ("provisioning" in target) {
|
|
100
397
|
await options.leaseStore.update(workspaceId, lease.epoch, {
|
|
101
398
|
status: "acquiring",
|
|
@@ -105,7 +402,11 @@ function createSandboxManager(options) {
|
|
|
105
402
|
status: "provisioning",
|
|
106
403
|
retryAfterMs: target.retryAfterMs,
|
|
107
404
|
epoch: lease.epoch,
|
|
108
|
-
homeRegion
|
|
405
|
+
homeRegion,
|
|
406
|
+
// Honest progress for the connect UI: which of the three boot paths
|
|
407
|
+
// this cycle is on. Derived from the same inputs that CHOSE the path
|
|
408
|
+
// above, so it cannot drift from what actually ran.
|
|
409
|
+
bootMode: resuming ? "resume" : ensure.bootSource?.kind === "driver-snapshot" ? "restore" : "cold-start"
|
|
109
410
|
};
|
|
110
411
|
}
|
|
111
412
|
const updated = await options.leaseStore.update(workspaceId, lease.epoch, {
|
|
@@ -117,7 +418,8 @@ function createSandboxManager(options) {
|
|
|
117
418
|
retryCount: 0,
|
|
118
419
|
nextRetryAt: null,
|
|
119
420
|
lastError: null,
|
|
120
|
-
labels: target.labels ?? ensure.labels
|
|
421
|
+
labels: target.labels ?? ensure.labels,
|
|
422
|
+
persistence: options.driver.metadata.persistence
|
|
121
423
|
});
|
|
122
424
|
const resolved = await leaseTarget(updated ?? lease);
|
|
123
425
|
if (resolved.status === "ready") return resolved;
|
|
@@ -153,6 +455,13 @@ function createSandboxManager(options) {
|
|
|
153
455
|
}
|
|
154
456
|
return {
|
|
155
457
|
async ensure(workspaceId, input) {
|
|
458
|
+
const egress = sandboxEgressDisposition(options.driver.metadata.egressControl, input.net);
|
|
459
|
+
if (egress.action === "withhold" && input.net) {
|
|
460
|
+
reportEgressUnenforced({ workspaceId, requested: input.net });
|
|
461
|
+
}
|
|
462
|
+
if (egress.action === "refuse") {
|
|
463
|
+
return { status: "unavailable", error: egress.reason, homeRegion: input.homeRegion };
|
|
464
|
+
}
|
|
156
465
|
const existing = await options.leaseStore.get(workspaceId);
|
|
157
466
|
if (existing?.nextRetryAt && existing.nextRetryAt > now()) {
|
|
158
467
|
if (existing.status === "acquiring") {
|
|
@@ -160,7 +469,8 @@ function createSandboxManager(options) {
|
|
|
160
469
|
status: "provisioning",
|
|
161
470
|
retryAfterMs: existing.nextRetryAt - now(),
|
|
162
471
|
epoch: existing.epoch,
|
|
163
|
-
homeRegion: existing.homeRegion
|
|
472
|
+
homeRegion: existing.homeRegion,
|
|
473
|
+
bootMode: leaseBootMode(existing)
|
|
164
474
|
};
|
|
165
475
|
}
|
|
166
476
|
return {
|
|
@@ -196,7 +506,8 @@ function createSandboxManager(options) {
|
|
|
196
506
|
status: "provisioning",
|
|
197
507
|
retryAfterMs: acquired.retryAfterMs,
|
|
198
508
|
epoch: acquired.lease.epoch,
|
|
199
|
-
homeRegion: acquired.lease.homeRegion
|
|
509
|
+
homeRegion: acquired.lease.homeRegion,
|
|
510
|
+
bootMode: leaseBootMode(acquired.lease)
|
|
200
511
|
};
|
|
201
512
|
}
|
|
202
513
|
return provision(workspaceId, acquired.lease, input.homeRegion, input);
|
|
@@ -224,23 +535,50 @@ function createSandboxManager(options) {
|
|
|
224
535
|
if (!options.driver.snapshot) return { ok: false, reason: "snapshot_unsupported" };
|
|
225
536
|
return { ok: true, ...await options.driver.snapshot(target) };
|
|
226
537
|
},
|
|
538
|
+
async checkpoint(workspaceId, input) {
|
|
539
|
+
return await checkpointLifecycle(workspaceId, "checkpoint", () => captureSandboxCheckpoint({
|
|
540
|
+
workspaceId,
|
|
541
|
+
request: input,
|
|
542
|
+
leaseStore: options.leaseStore,
|
|
543
|
+
target: (id) => this.target(id),
|
|
544
|
+
snapshot: (id) => this.snapshot(id),
|
|
545
|
+
now
|
|
546
|
+
}));
|
|
547
|
+
},
|
|
548
|
+
async restore(workspaceId, input) {
|
|
549
|
+
return await checkpointLifecycle(workspaceId, "restore", () => restoreSandboxCheckpoint({
|
|
550
|
+
workspaceId,
|
|
551
|
+
request: input,
|
|
552
|
+
leaseStore: options.leaseStore,
|
|
553
|
+
ensure: (id, next) => this.ensure(id, next),
|
|
554
|
+
now
|
|
555
|
+
}));
|
|
556
|
+
},
|
|
227
557
|
async stop(workspaceId) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
558
|
+
return await mutationLifecycle(workspaceId, "stop", async () => {
|
|
559
|
+
const lease = await options.leaseStore.get(workspaceId);
|
|
560
|
+
if (lease?.status === "stopped") return { ok: true, status: "stopped" };
|
|
561
|
+
const target = await this.target(workspaceId);
|
|
562
|
+
if (target.status !== "ready") return { ok: false, reason: target.reason };
|
|
563
|
+
if (options.driver.metadata.hostStopBehavior !== "not-supported") {
|
|
564
|
+
await (options.driver.suspend ?? options.driver.stop)?.(target);
|
|
565
|
+
} else {
|
|
566
|
+
await options.driver.stop?.(target);
|
|
567
|
+
}
|
|
568
|
+
await options.leaseStore.update(workspaceId, target.epoch, { status: "stopped" });
|
|
569
|
+
return { ok: true, status: "stopped" };
|
|
570
|
+
});
|
|
237
571
|
},
|
|
238
572
|
async destroy(workspaceId) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
573
|
+
return await mutationLifecycle(workspaceId, "destroy", async () => {
|
|
574
|
+
const lease = await options.leaseStore.get(workspaceId);
|
|
575
|
+
if (lease?.status === "destroyed") return { ok: true, status: "destroyed" };
|
|
576
|
+
const target = await this.target(workspaceId);
|
|
577
|
+
if (target.status !== "ready") return { ok: false, reason: target.reason };
|
|
578
|
+
await options.driver.destroy?.(target);
|
|
579
|
+
await options.leaseStore.update(workspaceId, target.epoch, { status: "destroyed" });
|
|
580
|
+
return { ok: true, status: "destroyed" };
|
|
581
|
+
});
|
|
244
582
|
},
|
|
245
583
|
async release(workspaceId) {
|
|
246
584
|
const lease = await options.leaseStore.get(workspaceId);
|
|
@@ -254,7 +592,23 @@ function createSandboxManager(options) {
|
|
|
254
592
|
destroyed: [],
|
|
255
593
|
kept: [],
|
|
256
594
|
skipped: [],
|
|
257
|
-
failed: []
|
|
595
|
+
failed: [],
|
|
596
|
+
listingUnsupported: true,
|
|
597
|
+
driver: options.driver.id
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
let listed;
|
|
601
|
+
try {
|
|
602
|
+
listed = await options.driver.list();
|
|
603
|
+
} catch (err) {
|
|
604
|
+
if (!isSandboxListingUnsupported(err)) throw err;
|
|
605
|
+
return {
|
|
606
|
+
destroyed: [],
|
|
607
|
+
kept: [],
|
|
608
|
+
skipped: [],
|
|
609
|
+
failed: [],
|
|
610
|
+
listingUnsupported: true,
|
|
611
|
+
driver: options.driver.id
|
|
258
612
|
};
|
|
259
613
|
}
|
|
260
614
|
const leases = new Map((await options.leaseStore.list()).map((lease) => [lease.workspaceId, lease]));
|
|
@@ -265,7 +619,7 @@ function createSandboxManager(options) {
|
|
|
265
619
|
failed: []
|
|
266
620
|
};
|
|
267
621
|
const appLabel = options.appLabel ?? DEFAULT_APP_LABEL;
|
|
268
|
-
for (const target of
|
|
622
|
+
for (const target of listed) {
|
|
269
623
|
if (target.labels?.app !== appLabel) {
|
|
270
624
|
result.skipped.push({ target, reason: "unmanaged_app_label" });
|
|
271
625
|
continue;
|
|
@@ -306,7 +660,7 @@ function createSandboxManager(options) {
|
|
|
306
660
|
const expectedEpoch = input.epoch ?? current.epoch;
|
|
307
661
|
if (current.epoch !== expectedEpoch) return { ok: false, reason: "runtime_lease_epoch_mismatch" };
|
|
308
662
|
const timestamp = input.now ?? now();
|
|
309
|
-
const status = input.status === "unhealthy" || input.ok
|
|
663
|
+
const status = input.status === "unhealthy" || !input.ok ? "unavailable" : input.ok ? "ready" : current.status;
|
|
310
664
|
const updated = await options.leaseStore.update(workspaceId, expectedEpoch, {
|
|
311
665
|
status,
|
|
312
666
|
...input.sandboxId ? { sandboxId: input.sandboxId } : {},
|
|
@@ -323,6 +677,19 @@ function createSandboxManager(options) {
|
|
|
323
677
|
}
|
|
324
678
|
export {
|
|
325
679
|
DEFAULT_WORKSPACE_RUNTIME_PORT,
|
|
680
|
+
SANDBOX_BASELINE_EGRESS_HOSTS,
|
|
681
|
+
SANDBOX_MODEL_PROVIDER_HOSTS,
|
|
682
|
+
SANDBOX_PACKAGE_REGISTRY_HOSTS,
|
|
326
683
|
applySandboxLeasePatch,
|
|
327
|
-
|
|
684
|
+
brokeredPlaceholderEnv,
|
|
685
|
+
brokeredSecretPlaceholder,
|
|
686
|
+
captureSandboxCheckpoint,
|
|
687
|
+
createSandboxManager,
|
|
688
|
+
hostedSandboxNetworkPolicy,
|
|
689
|
+
isSandboxListingUnsupported,
|
|
690
|
+
restoreSandboxCheckpoint,
|
|
691
|
+
sandboxEgressDisposition,
|
|
692
|
+
sandboxNetworkPolicyWithBrokeredHosts,
|
|
693
|
+
sandboxSourceHost,
|
|
694
|
+
validateSandboxPersistenceCapabilities
|
|
328
695
|
};
|
package/dist/json.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading untrusted JSON: driver API responses, CLI output, stored records.
|
|
3
|
+
*
|
|
4
|
+
* Drivers all face the same problem — a provider hands back `any`, and the code
|
|
5
|
+
* needs a few named fields out of it. Narrowing through these keeps the check
|
|
6
|
+
* and the type in the same place, instead of each site asserting a shape it
|
|
7
|
+
* never verified. The object check is `@claxedo/helpers/guards`, re-exported
|
|
8
|
+
* under the names the drivers use.
|
|
9
|
+
*/
|
|
10
|
+
export { asRecord as record, isRecord } from "@claxedo/helpers/guards";
|
|
11
|
+
/** A non-empty string, or undefined — the shape almost every provider field wants. */
|
|
12
|
+
export declare function text(input: unknown): string | undefined;
|
|
13
|
+
export declare function num(input: unknown): number | undefined;
|
package/dist/lease-policy.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SandboxLeaseRow } from "./lease-types";
|
|
2
|
-
import type { SandboxDriverID } from "
|
|
2
|
+
import type { SandboxDriverID } from "@claxedo/sandbox-contract";
|
|
3
3
|
export type SandboxDecision = {
|
|
4
4
|
action: "skip";
|
|
5
5
|
reason: string;
|
|
@@ -44,6 +44,9 @@ export type SandboxDriverPlacement = {
|
|
|
44
44
|
export declare const DEFAULT_WORKSPACE_HOST_DECISION_CONFIG: SandboxDecisionConfig;
|
|
45
45
|
export declare function sandboxDriverPlacement(driver: SandboxDriverID): SandboxDriverPlacement;
|
|
46
46
|
export declare function decideSandboxStart(lease: SandboxLeaseRow, placement: SandboxDriverPlacement, now: number, config?: SandboxDecisionConfig): SandboxDecision;
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Health-failure policy is placement-independent: retry budget and backoff are
|
|
49
|
+
* the same question for every driver, so this deliberately takes no placement.
|
|
50
|
+
*/
|
|
51
|
+
export declare function decideSandboxHealthFailure(lease: SandboxLeaseRow, now: number, config?: SandboxDecisionConfig): SandboxDecision;
|
|
49
52
|
export declare function nextSandboxRetryAt(retryCount: number, now: number, config?: SandboxDecisionConfig): number | null;
|