@schemavaults/sandbox-management-sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -2
  3. package/dist/commands.d.ts +16 -0
  4. package/dist/commands.js +99 -0
  5. package/dist/dbh/index.d.ts +4 -0
  6. package/dist/dbh/index.js +2 -0
  7. package/dist/dbh/migration.d.ts +18 -0
  8. package/dist/dbh/migration.js +41 -0
  9. package/dist/dbh/store.d.ts +42 -0
  10. package/dist/dbh/store.js +105 -0
  11. package/dist/docker/egress-proxy/config.d.ts +20 -0
  12. package/dist/docker/egress-proxy/config.js +29 -0
  13. package/dist/docker/egress-proxy/main.d.ts +1 -0
  14. package/dist/docker/egress-proxy/main.js +19 -0
  15. package/dist/docker/egress-proxy/policy.d.ts +49 -0
  16. package/dist/docker/egress-proxy/policy.js +89 -0
  17. package/dist/docker/egress-proxy/server.d.ts +25 -0
  18. package/dist/docker/egress-proxy/server.js +225 -0
  19. package/dist/docker/egress.d.ts +41 -0
  20. package/dist/docker/egress.js +67 -0
  21. package/dist/docker/index.d.ts +3 -0
  22. package/dist/docker/index.js +2 -0
  23. package/dist/docker/provider.d.ts +101 -0
  24. package/dist/docker/provider.js +373 -0
  25. package/dist/docker/tar.d.ts +20 -0
  26. package/dist/docker/tar.js +124 -0
  27. package/dist/errors.d.ts +40 -0
  28. package/dist/errors.js +67 -0
  29. package/dist/in-memory-store.d.ts +11 -0
  30. package/dist/in-memory-store.js +40 -0
  31. package/dist/index.d.ts +12 -0
  32. package/dist/index.js +8 -0
  33. package/dist/internal/base64.d.ts +7 -0
  34. package/dist/internal/base64.js +20 -0
  35. package/dist/internal/output.d.ts +6 -0
  36. package/dist/internal/output.js +25 -0
  37. package/dist/internal/posix-path.d.ts +8 -0
  38. package/dist/internal/posix-path.js +38 -0
  39. package/dist/manager.d.ts +126 -0
  40. package/dist/manager.js +477 -0
  41. package/dist/network.d.ts +27 -0
  42. package/dist/network.js +128 -0
  43. package/dist/provider.d.ts +77 -0
  44. package/dist/provider.js +1 -0
  45. package/dist/shell-main-process.d.ts +27 -0
  46. package/dist/shell-main-process.js +94 -0
  47. package/dist/store.d.ts +36 -0
  48. package/dist/store.js +19 -0
  49. package/dist/testing/index.d.ts +3 -0
  50. package/dist/testing/index.js +2 -0
  51. package/dist/testing/mock-provider.d.ts +99 -0
  52. package/dist/testing/mock-provider.js +181 -0
  53. package/dist/types.d.ts +158 -0
  54. package/dist/types.js +21 -0
  55. package/dist/vercel/index.d.ts +2 -0
  56. package/dist/vercel/index.js +1 -0
  57. package/dist/vercel/provider.d.ts +125 -0
  58. package/dist/vercel/provider.js +190 -0
  59. package/dist/web-storage/index.d.ts +2 -0
  60. package/dist/web-storage/index.js +1 -0
  61. package/dist/web-storage/store.d.ts +53 -0
  62. package/dist/web-storage/store.js +118 -0
  63. package/package.json +117 -4
@@ -0,0 +1,477 @@
1
+ import { gitCloneCommand, normalizeCommand, resolveSandboxPath, validateSpec } from "./commands.js";
2
+ import { ProviderNotFoundError, ProviderSandboxMissingError, ProviderSandboxStoppedError, SandboxBusyError, SandboxCommandError, SandboxError, SandboxNotFoundError, SandboxStateError, } from "./errors.js";
3
+ import { assertNetworkPolicySupported } from "./network.js";
4
+ import { TRANSITIONAL_STATUSES } from "./types.js";
5
+ const DEFAULT_TRANSITION_TIMEOUT_MS = 10 * 60 * 1000;
6
+ const DEFAULT_POLL_INTERVAL_MS = 250;
7
+ export function generateSandboxId() {
8
+ return `sbx-${crypto.randomUUID().replaceAll("-", "")}`;
9
+ }
10
+ /**
11
+ * Creates, lists and drives sandboxes across providers, keeping their state in
12
+ * a `SandboxStore`. Safe to use from many processes at once: every status
13
+ * change is a compare-and-set on the record's version.
14
+ */
15
+ export class SandboxManager {
16
+ providers = new Map();
17
+ store;
18
+ defaultProvider;
19
+ autoResume;
20
+ transitionTimeoutMs;
21
+ pollIntervalMs;
22
+ generateId;
23
+ now;
24
+ constructor(options) {
25
+ for (const provider of options.providers) {
26
+ if (this.providers.has(provider.name)) {
27
+ throw new SandboxError(`Duplicate sandbox provider name "${provider.name}"`);
28
+ }
29
+ this.providers.set(provider.name, provider);
30
+ }
31
+ this.store = options.store;
32
+ this.defaultProvider =
33
+ options.defaultProvider ?? (options.providers.length === 1 ? options.providers[0]?.name : undefined);
34
+ this.autoResume = options.autoResume ?? true;
35
+ this.transitionTimeoutMs = options.transitionTimeoutMs ?? DEFAULT_TRANSITION_TIMEOUT_MS;
36
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
37
+ this.generateId = options.generateId ?? generateSandboxId;
38
+ this.now = options.now ?? (() => new Date());
39
+ }
40
+ /**
41
+ * Create a sandbox, clone `spec.git`, run `spec.setup` and launch
42
+ * `spec.start`. Resolves once the sandbox is running. On failure the record
43
+ * is marked `failed` and the provider sandbox is kept for inspection; call
44
+ * `destroy` to remove it.
45
+ */
46
+ async create(options) {
47
+ const providerName = options.provider ?? this.defaultProvider;
48
+ if (!providerName) {
49
+ throw new SandboxError("No provider given and no defaultProvider configured");
50
+ }
51
+ const provider = this.getProvider(providerName);
52
+ validateSpec(options.spec);
53
+ assertNetworkPolicySupported(provider, options.spec.network);
54
+ const now = this.now();
55
+ let record = {
56
+ id: this.generateId(),
57
+ provider: provider.name,
58
+ providerRef: null,
59
+ status: "provisioning",
60
+ spec: structuredClone(options.spec),
61
+ ownerId: options.ownerId ?? null,
62
+ labels: { ...options.labels },
63
+ metadata: structuredClone(options.metadata ?? {}),
64
+ error: null,
65
+ version: 0,
66
+ createdAt: now,
67
+ updatedAt: now,
68
+ expiresAt: null,
69
+ };
70
+ await this.store.insert(record);
71
+ try {
72
+ const workdir = this.workdirFor(record);
73
+ const { providerRef } = await provider.create({
74
+ id: record.id,
75
+ spec: record.spec,
76
+ workdir,
77
+ ownerId: record.ownerId,
78
+ labels: record.labels,
79
+ });
80
+ record = await this.mustUpdate(record, {
81
+ providerRef,
82
+ status: "setting_up",
83
+ expiresAt: this.expiryFor(record.spec),
84
+ });
85
+ const sandbox = this.toProviderSandbox(record);
86
+ if (record.spec.git) {
87
+ await this.runStep(provider, sandbox, "git clone", gitCloneCommand(record.spec.git, workdir), options);
88
+ }
89
+ const setup = record.spec.setup === undefined ? [] : [].concat(record.spec.setup);
90
+ for (const [index, step] of setup.entries()) {
91
+ await this.runStep(provider, sandbox, `setup step ${index + 1}`, normalizeCommand(step, workdir), options);
92
+ }
93
+ record = await this.mustUpdate(record, { status: "starting" });
94
+ if (record.spec.start) {
95
+ await provider.startMainProcess(sandbox, normalizeCommand(record.spec.start, workdir));
96
+ }
97
+ return await this.mustUpdate(record, { status: "running" });
98
+ }
99
+ catch (error) {
100
+ await this.markFailed(record, error);
101
+ throw error;
102
+ }
103
+ }
104
+ /** Resolves the record, or throws `SandboxNotFoundError`. */
105
+ async get(id) {
106
+ const record = await this.store.get(id);
107
+ if (!record)
108
+ throw new SandboxNotFoundError(id);
109
+ return record;
110
+ }
111
+ /** Resolves the record, or `null` if it does not exist. */
112
+ async find(id) {
113
+ return this.store.get(id);
114
+ }
115
+ async list(filter) {
116
+ return this.store.list(filter);
117
+ }
118
+ /** Run a one-shot command and wait for it to finish. A non-zero exit does not throw. */
119
+ async exec(id, command, options) {
120
+ return this.withRunning(id, "exec in", (provider, sandbox) => provider.exec(sandbox, normalizeCommand(command, sandbox.workdir), options));
121
+ }
122
+ /**
123
+ * Wake a stopped sandbox and relaunch `spec.resume` (or `spec.start`) as its
124
+ * main process. Also retries a `failed` sandbox that still has a provider
125
+ * sandbox; `setup` is not re-run. No-op if it is already running.
126
+ */
127
+ async resume(id) {
128
+ for (;;) {
129
+ const record = await this.settle(id);
130
+ if (record.status === "running")
131
+ return record;
132
+ const resumable = record.status === "stopped" || (record.status === "failed" && record.providerRef !== null);
133
+ if (!resumable)
134
+ throw new SandboxStateError(id, record.status, "resume");
135
+ const resumed = await this.tryResume(record);
136
+ if (resumed)
137
+ return resumed;
138
+ }
139
+ }
140
+ /** Stop a running sandbox, keeping its filesystem. No-op if it is already stopped. */
141
+ async stop(id) {
142
+ for (;;) {
143
+ const record = await this.settle(id);
144
+ if (record.status === "stopped")
145
+ return record;
146
+ if (record.status !== "running")
147
+ throw new SandboxStateError(id, record.status, "stop");
148
+ const provider = this.getProvider(record.provider);
149
+ const claimed = await this.store.update(id, record.version, { status: "stopping" });
150
+ if (!claimed)
151
+ continue;
152
+ try {
153
+ await provider.stop(this.toProviderSandbox(claimed));
154
+ }
155
+ catch (error) {
156
+ if (error instanceof ProviderSandboxMissingError) {
157
+ await this.markFailed(claimed, error);
158
+ }
159
+ else {
160
+ await this.store.update(id, claimed.version, { status: "running", error: errorMessage(error) });
161
+ }
162
+ throw error;
163
+ }
164
+ return this.mustUpdate(claimed, { status: "stopped", expiresAt: null });
165
+ }
166
+ }
167
+ /** Delete the provider sandbox. The record is kept with status `destroyed`. */
168
+ async destroy(id) {
169
+ for (;;) {
170
+ const record = await this.settle(id);
171
+ if (record.status === "destroyed")
172
+ return record;
173
+ const provider = this.getProvider(record.provider);
174
+ const claimed = await this.store.update(id, record.version, { status: "destroying" });
175
+ if (!claimed)
176
+ continue;
177
+ try {
178
+ if (claimed.providerRef)
179
+ await provider.destroy(this.toProviderSandbox(claimed));
180
+ }
181
+ catch (error) {
182
+ await this.markFailed(claimed, error);
183
+ throw error;
184
+ }
185
+ return this.mustUpdate(claimed, { status: "destroyed", expiresAt: null });
186
+ }
187
+ }
188
+ /** A URL that reaches `port` (which must be listed in `spec.ports`). */
189
+ async getUrl(id, port) {
190
+ return this.withRunning(id, "get a URL for", (provider, sandbox) => {
191
+ if (!sandbox.spec.ports?.includes(port)) {
192
+ throw new SandboxError(`Port ${port} is not exposed; add it to spec.ports`);
193
+ }
194
+ return provider.getUrl(sandbox, port);
195
+ });
196
+ }
197
+ async writeFiles(id, files) {
198
+ const encoder = new TextEncoder();
199
+ await this.withRunning(id, "write files to", (provider, sandbox) => provider.writeFiles(sandbox, files.map((file) => ({
200
+ path: resolveSandboxPath(sandbox.workdir, file.path),
201
+ content: typeof file.content === "string" ? encoder.encode(file.content) : file.content,
202
+ mode: file.mode ?? 0o644,
203
+ }))));
204
+ }
205
+ /** Resolves the file's bytes, or `null` if it does not exist. */
206
+ async readFile(id, path) {
207
+ return this.withRunning(id, "read files from", (provider, sandbox) => provider.readFile(sandbox, resolveSandboxPath(sandbox.workdir, path)));
208
+ }
209
+ async readTextFile(id, path) {
210
+ const bytes = await this.readFile(id, path);
211
+ return bytes === null ? null : new TextDecoder().decode(bytes);
212
+ }
213
+ /** State of the detached main process, or `null` if none was launched. */
214
+ async getMainProcess(id) {
215
+ return this.withRunning(id, "inspect", (provider, sandbox) => provider.getMainProcess(sandbox));
216
+ }
217
+ /** The main process's full log so far. */
218
+ async readLogs(id) {
219
+ let output = "";
220
+ for await (const chunk of this.logs(id))
221
+ output += chunk;
222
+ return output;
223
+ }
224
+ /**
225
+ * Stream the main process's log, from the beginning. Only the first read
226
+ * auto-resumes a stopped sandbox; when following, the stream ends once the
227
+ * sandbox stops or `signal` aborts.
228
+ */
229
+ async *logs(id, options = {}) {
230
+ const decoder = new TextDecoder();
231
+ let offset = 0;
232
+ let first = true;
233
+ for (;;) {
234
+ if (options.signal?.aborted)
235
+ break;
236
+ const chunk = first
237
+ ? await this.withRunning(id, "read logs of", (provider, sandbox) => provider.readMainLog(sandbox, offset))
238
+ : await this.readMainLogIfRunning(id, offset);
239
+ first = false;
240
+ if (chunk === null)
241
+ break;
242
+ offset = chunk.nextOffset;
243
+ if (chunk.data.length > 0) {
244
+ yield decoder.decode(chunk.data, { stream: true });
245
+ continue;
246
+ }
247
+ if (!options.follow)
248
+ break;
249
+ await sleep(options.pollIntervalMs ?? 1000, options.signal);
250
+ }
251
+ const rest = decoder.decode();
252
+ if (rest)
253
+ yield rest;
254
+ }
255
+ /** Reads the log without resuming; `null` once the sandbox is no longer running. */
256
+ async readMainLogIfRunning(id, offset) {
257
+ const record = await this.get(id);
258
+ if (record.status !== "running")
259
+ return null;
260
+ try {
261
+ return await this.getProvider(record.provider).readMainLog(this.toProviderSandbox(record), offset);
262
+ }
263
+ catch (error) {
264
+ if (error instanceof ProviderSandboxStoppedError || error instanceof ProviderSandboxMissingError)
265
+ return null;
266
+ throw error;
267
+ }
268
+ }
269
+ /** Push back the sandbox's expiry (and the provider's own timeout, if it has one). */
270
+ async extendTimeout(id, durationMs) {
271
+ await this.withRunning(id, "extend the timeout of", async (provider, sandbox) => {
272
+ await provider.extendTimeout?.(sandbox, durationMs);
273
+ });
274
+ for (;;) {
275
+ const record = await this.get(id);
276
+ const base = Math.max(record.expiresAt?.getTime() ?? 0, this.now().getTime());
277
+ const updated = await this.store.update(id, record.version, { expiresAt: new Date(base + durationMs) });
278
+ if (updated)
279
+ return updated;
280
+ }
281
+ }
282
+ /**
283
+ * Stop every running sandbox whose `expiresAt` has passed. Run this
284
+ * periodically (e.g. from a cron job) for providers without native
285
+ * timeouts, such as Docker.
286
+ */
287
+ async stopExpired() {
288
+ const now = this.now().getTime();
289
+ const expired = (await this.store.list({ status: "running" })).filter((record) => record.expiresAt !== null && record.expiresAt.getTime() <= now);
290
+ const stopped = [];
291
+ for (const record of expired) {
292
+ try {
293
+ stopped.push(await this.stop(record.id));
294
+ }
295
+ catch {
296
+ // Leave it for the next run; the error is recorded on the sandbox where possible.
297
+ }
298
+ }
299
+ return stopped;
300
+ }
301
+ /**
302
+ * Bring records in line with what the providers report: running sandboxes
303
+ * the provider has stopped become `stopped`, vanished ones become `failed`,
304
+ * and transitions abandoned for longer than the transition timeout (e.g. a
305
+ * crashed process) are settled. Defaults to every non-destroyed sandbox;
306
+ * sandboxes on unregistered providers, or whose provider errors, are
307
+ * skipped. Resolves the records that changed.
308
+ */
309
+ async reconcile(ids) {
310
+ const records = ids
311
+ ? (await Promise.all(ids.map((id) => this.store.get(id)))).filter((r) => r !== null)
312
+ : await this.store.list();
313
+ const changed = [];
314
+ for (const record of records) {
315
+ const patch = await this.reconcilePatch(record).catch(() => null);
316
+ if (!patch)
317
+ continue;
318
+ const updated = await this.store.update(record.id, record.version, patch);
319
+ if (updated)
320
+ changed.push(updated);
321
+ }
322
+ return changed;
323
+ }
324
+ async reconcilePatch(record) {
325
+ if (record.status === "destroyed")
326
+ return null;
327
+ const stale = TRANSITIONAL_STATUSES.includes(record.status) &&
328
+ this.now().getTime() - record.updatedAt.getTime() > this.transitionTimeoutMs;
329
+ if (!record.providerRef) {
330
+ return stale ? { status: "failed", error: `Interrupted while ${record.status}` } : null;
331
+ }
332
+ const provider = this.providers.get(record.provider);
333
+ if (!provider)
334
+ return null;
335
+ const providerStatus = await provider.status(this.toProviderSandbox(record));
336
+ if (providerStatus === "missing") {
337
+ if (record.status === "destroying")
338
+ return { status: "destroyed", expiresAt: null };
339
+ if (record.status === "failed")
340
+ return null;
341
+ if (TRANSITIONAL_STATUSES.includes(record.status) && !stale)
342
+ return null;
343
+ return { status: "failed", error: "Sandbox no longer exists on the provider" };
344
+ }
345
+ if (record.status === "running" && providerStatus === "stopped") {
346
+ return { status: "stopped", expiresAt: null };
347
+ }
348
+ if (stale) {
349
+ if (record.status === "stopping" && providerStatus === "stopped") {
350
+ return { status: "stopped", expiresAt: null };
351
+ }
352
+ return { status: "failed", error: `Interrupted while ${record.status}` };
353
+ }
354
+ return null;
355
+ }
356
+ /** Runs `fn` against a running sandbox, auto-resuming it if it is (or turns out to be) stopped. */
357
+ async withRunning(id, operation, fn) {
358
+ let record = await this.ensureRunning(id, operation);
359
+ let provider = this.getProvider(record.provider);
360
+ try {
361
+ return await fn(provider, this.toProviderSandbox(record));
362
+ }
363
+ catch (error) {
364
+ if (error instanceof ProviderSandboxMissingError) {
365
+ await this.markFailed(record, error);
366
+ throw error;
367
+ }
368
+ if (!(error instanceof ProviderSandboxStoppedError))
369
+ throw error;
370
+ // Stopped behind our back, e.g. by a provider-side timeout.
371
+ await this.store.update(id, record.version, { status: "stopped", expiresAt: null });
372
+ record = await this.ensureRunning(id, operation);
373
+ provider = this.getProvider(record.provider);
374
+ return await fn(provider, this.toProviderSandbox(record));
375
+ }
376
+ }
377
+ async ensureRunning(id, operation) {
378
+ for (;;) {
379
+ const record = await this.settle(id);
380
+ if (record.status === "running")
381
+ return record;
382
+ if (record.status !== "stopped" || !this.autoResume) {
383
+ throw new SandboxStateError(id, record.status, operation);
384
+ }
385
+ const resumed = await this.tryResume(record);
386
+ if (resumed)
387
+ return resumed;
388
+ }
389
+ }
390
+ /** Claims the record for resuming. Resolves `null` if another caller claimed it first. */
391
+ async tryResume(record) {
392
+ const provider = this.getProvider(record.provider);
393
+ const claimed = await this.store.update(record.id, record.version, { status: "resuming", error: null });
394
+ if (!claimed)
395
+ return null;
396
+ try {
397
+ const sandbox = this.toProviderSandbox(claimed);
398
+ await provider.wake(sandbox);
399
+ const command = claimed.spec.resume ?? claimed.spec.start;
400
+ if (command) {
401
+ await provider.startMainProcess(sandbox, normalizeCommand(command, sandbox.workdir));
402
+ }
403
+ return await this.mustUpdate(claimed, { status: "running", expiresAt: this.expiryFor(claimed.spec) });
404
+ }
405
+ catch (error) {
406
+ await this.markFailed(claimed, error);
407
+ throw error;
408
+ }
409
+ }
410
+ /** Waits until the record is not in a transitional status. */
411
+ async settle(id) {
412
+ const deadline = Date.now() + this.transitionTimeoutMs;
413
+ for (;;) {
414
+ const record = await this.get(id);
415
+ if (!TRANSITIONAL_STATUSES.includes(record.status))
416
+ return record;
417
+ if (Date.now() >= deadline)
418
+ throw new SandboxBusyError(id, record.status);
419
+ await sleep(this.pollIntervalMs);
420
+ }
421
+ }
422
+ async runStep(provider, sandbox, step, command, options) {
423
+ const result = await provider.exec(sandbox, command, {
424
+ onStdout: options.onStdout,
425
+ onStderr: options.onStderr,
426
+ });
427
+ if (result.exitCode !== 0)
428
+ throw new SandboxCommandError(step, result);
429
+ }
430
+ /** Update that must succeed because this caller holds the record's transitional status. */
431
+ async mustUpdate(record, patch) {
432
+ const updated = await this.store.update(record.id, record.version, patch);
433
+ if (!updated) {
434
+ throw new SandboxError(`Sandbox "${record.id}" was modified concurrently while ${record.status}`);
435
+ }
436
+ return updated;
437
+ }
438
+ async markFailed(record, error) {
439
+ await this.store
440
+ .update(record.id, record.version, { status: "failed", error: errorMessage(error) })
441
+ .catch(() => null);
442
+ }
443
+ getProvider(name) {
444
+ const provider = this.providers.get(name);
445
+ if (!provider)
446
+ throw new ProviderNotFoundError(name);
447
+ return provider;
448
+ }
449
+ workdirFor(record) {
450
+ return record.spec.workdir ?? this.getProvider(record.provider).defaultWorkdir;
451
+ }
452
+ toProviderSandbox(record) {
453
+ if (!record.providerRef) {
454
+ throw new SandboxError(`Sandbox "${record.id}" has no provider sandbox`);
455
+ }
456
+ return { id: record.id, providerRef: record.providerRef, spec: record.spec, workdir: this.workdirFor(record) };
457
+ }
458
+ expiryFor(spec) {
459
+ return spec.timeoutMs === undefined ? null : new Date(this.now().getTime() + spec.timeoutMs);
460
+ }
461
+ }
462
+ function errorMessage(error) {
463
+ return error instanceof Error ? error.message : String(error);
464
+ }
465
+ function sleep(ms, signal) {
466
+ return new Promise((resolve) => {
467
+ if (signal?.aborted)
468
+ return resolve();
469
+ const timer = setTimeout(done, ms);
470
+ signal?.addEventListener("abort", done, { once: true });
471
+ function done() {
472
+ clearTimeout(timer);
473
+ signal?.removeEventListener("abort", done);
474
+ resolve();
475
+ }
476
+ });
477
+ }
@@ -0,0 +1,27 @@
1
+ import type { SandboxProvider } from "./provider.js";
2
+ import type { SandboxNetworkPolicy, SandboxNetworkPolicyKind } from "./types.js";
3
+ /** `"allow-all"` for an unset policy, otherwise the policy's kind. */
4
+ export declare function networkPolicyKind(policy: SandboxNetworkPolicy | undefined): SandboxNetworkPolicyKind;
5
+ /** Throws a `SandboxError` unless `provider` declares that it enforces `policy`. */
6
+ export declare function assertNetworkPolicySupported(provider: Pick<SandboxProvider, "name" | "networkPolicies">, policy: SandboxNetworkPolicy | undefined): void;
7
+ /** Throws a `SandboxError` describing the first problem found in `policy`. */
8
+ export declare function validateNetworkPolicy(policy: unknown): void;
9
+ /** Adds the single-host prefix (`/32` or `/128`) to a bare IP address. Expects a valid value. */
10
+ export declare function normalizeCidr(value: string): string;
11
+ /** An IPv4 or IPv6 address as a number (32 or 128 bits). */
12
+ export interface IpAddress {
13
+ version: 4 | 6;
14
+ value: bigint;
15
+ }
16
+ /** An IP range; `network` holds the address's first `prefix` bits. */
17
+ export interface CidrRange {
18
+ version: 4 | 6;
19
+ prefix: number;
20
+ network: bigint;
21
+ }
22
+ /** Parses an IPv4 / IPv6 address with an optional `/prefix`, or returns `null`. */
23
+ export declare function parseCidr(value: string): CidrRange | null;
24
+ /** Whether `range` contains `ip`. Addresses of the other IP version are never contained. */
25
+ export declare function cidrContains(range: CidrRange, ip: IpAddress): boolean;
26
+ /** Parses a plain IPv4 or IPv6 address (no zone ID), or returns `null`. */
27
+ export declare function parseIp(address: string): IpAddress | null;
@@ -0,0 +1,128 @@
1
+ import { SandboxError } from "./errors.js";
2
+ const ALLOWLIST_FIELDS = ["allowDomains", "allowCidrs", "denyCidrs"];
3
+ const DOMAIN_LABEL = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?";
4
+ const DOMAIN = new RegExp(`^(?:\\*\\.)?(?:${DOMAIN_LABEL}\\.)*${DOMAIN_LABEL}$`, "i");
5
+ const IPV4_OCTET = /^(?:0|[1-9]\d{0,2})$/;
6
+ const IPV6_GROUP = /^[0-9a-f]{1,4}$/i;
7
+ const PREFIX = /^(?:0|[1-9]\d{0,2})$/;
8
+ /** `"allow-all"` for an unset policy, otherwise the policy's kind. */
9
+ export function networkPolicyKind(policy) {
10
+ return policy === undefined || policy === "allow-all" ? "allow-all" : policy === "deny-all" ? "deny-all" : "allowlist";
11
+ }
12
+ /** Throws a `SandboxError` unless `provider` declares that it enforces `policy`. */
13
+ export function assertNetworkPolicySupported(provider, policy) {
14
+ const kind = networkPolicyKind(policy);
15
+ if (kind === "allow-all" || provider.networkPolicies?.includes(kind))
16
+ return;
17
+ const supported = provider.networkPolicies?.length
18
+ ? `it enforces only ${provider.networkPolicies.map((name) => `"${name}"`).join(", ")}`
19
+ : "it enforces no network restrictions";
20
+ throw new SandboxError(`Provider "${provider.name}" cannot enforce ${kind === "allowlist" ? "a network allowlist" : 'a "deny-all" network policy'} (spec.network): ${supported}`);
21
+ }
22
+ /** Throws a `SandboxError` describing the first problem found in `policy`. */
23
+ export function validateNetworkPolicy(policy) {
24
+ if (policy === undefined || policy === "allow-all" || policy === "deny-all")
25
+ return;
26
+ if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
27
+ throw new SandboxError(`network must be "allow-all", "deny-all" or { allowDomains?, allowCidrs?, denyCidrs? }`);
28
+ }
29
+ const allowlist = policy;
30
+ for (const key of Object.keys(allowlist)) {
31
+ if (!ALLOWLIST_FIELDS.includes(key)) {
32
+ throw new SandboxError(`Unknown field network.${key}; expected allowDomains, allowCidrs or denyCidrs`);
33
+ }
34
+ }
35
+ for (const domain of stringList(allowlist, "allowDomains")) {
36
+ if (parseCidr(domain)) {
37
+ throw new SandboxError(`network.allowDomains has the IP address "${domain}"; put it in allowCidrs`);
38
+ }
39
+ if (domain !== "*" && !(domain.length <= 253 && DOMAIN.test(domain))) {
40
+ throw new SandboxError(`Invalid domain "${domain}" in network.allowDomains; expected a host name such as "github.com" or "*.github.com"`);
41
+ }
42
+ }
43
+ for (const field of ["allowCidrs", "denyCidrs"]) {
44
+ for (const cidr of stringList(allowlist, field)) {
45
+ if (!parseCidr(cidr)) {
46
+ throw new SandboxError(`Invalid IP address or CIDR "${cidr}" in network.${field}; expected e.g. "203.0.113.10" or "10.0.0.0/8"`);
47
+ }
48
+ }
49
+ }
50
+ }
51
+ /** Adds the single-host prefix (`/32` or `/128`) to a bare IP address. Expects a valid value. */
52
+ export function normalizeCidr(value) {
53
+ if (value.includes("/"))
54
+ return value;
55
+ return `${value}/${parseIp(value)?.version === 6 ? 128 : 32}`;
56
+ }
57
+ /** Parses an IPv4 / IPv6 address with an optional `/prefix`, or returns `null`. */
58
+ export function parseCidr(value) {
59
+ const [address = "", prefix, ...extra] = value.split("/");
60
+ if (extra.length > 0)
61
+ return null;
62
+ const ip = parseIp(address);
63
+ if (ip === null)
64
+ return null;
65
+ const bits = ip.version === 4 ? 32 : 128;
66
+ if (prefix !== undefined && !(PREFIX.test(prefix) && Number(prefix) <= bits))
67
+ return null;
68
+ const length = prefix === undefined ? bits : Number(prefix);
69
+ return { version: ip.version, prefix: length, network: ip.value >> BigInt(bits - length) };
70
+ }
71
+ /** Whether `range` contains `ip`. Addresses of the other IP version are never contained. */
72
+ export function cidrContains(range, ip) {
73
+ const bits = range.version === 4 ? 32 : 128;
74
+ return range.version === ip.version && ip.value >> BigInt(bits - range.prefix) === range.network;
75
+ }
76
+ /** Parses a plain IPv4 or IPv6 address (no zone ID), or returns `null`. */
77
+ export function parseIp(address) {
78
+ const v4 = parseIPv4(address);
79
+ if (v4 !== null)
80
+ return { version: 4, value: v4 };
81
+ const v6 = parseIPv6(address);
82
+ return v6 === null ? null : { version: 6, value: v6 };
83
+ }
84
+ function stringList(allowlist, field) {
85
+ const value = allowlist[field];
86
+ if (value === undefined)
87
+ return [];
88
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
89
+ throw new SandboxError(`network.${field} must be an array of strings`);
90
+ }
91
+ return value;
92
+ }
93
+ function parseIPv4(address) {
94
+ const octets = address.split(".");
95
+ if (octets.length !== 4 || !octets.every((octet) => IPV4_OCTET.test(octet) && Number(octet) <= 255))
96
+ return null;
97
+ return octets.reduce((value, octet) => (value << 8n) | BigInt(octet), 0n);
98
+ }
99
+ function parseIPv6(address) {
100
+ let rest = address;
101
+ let embedded = [];
102
+ const last = rest.slice(rest.lastIndexOf(":") + 1);
103
+ if (last.includes(".")) {
104
+ // An embedded IPv4 address, e.g. `::ffff:192.0.2.1`, stands for the last two groups.
105
+ const v4 = parseIPv4(last);
106
+ if (v4 === null || last === rest)
107
+ return null;
108
+ rest = rest.slice(0, -last.length);
109
+ if (!rest.endsWith("::"))
110
+ rest = rest.slice(0, -1);
111
+ embedded = [v4 >> 16n, v4 & 0xffffn];
112
+ }
113
+ const halves = rest.split("::");
114
+ if (halves.length > 2)
115
+ return null;
116
+ const split = (part) => (part ? part.split(":") : []);
117
+ const head = split(halves[0]);
118
+ const tail = split(halves[1]);
119
+ if (![...head, ...tail].every((group) => IPV6_GROUP.test(group)))
120
+ return null;
121
+ const groups = 8 - embedded.length;
122
+ const count = head.length + tail.length;
123
+ if (halves.length === 2 ? count >= groups : count !== groups)
124
+ return null;
125
+ const zeros = Array.from({ length: groups - count }, () => 0n);
126
+ const values = [...head.map((group) => BigInt(`0x${group}`)), ...zeros, ...tail.map((group) => BigInt(`0x${group}`)), ...embedded];
127
+ return values.reduce((value, group) => (value << 16n) | group, 0n);
128
+ }