@aerol-ai/aerolvm-sdk 0.5.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,134 @@
1
1
  import { basename } from "node:path";
2
2
  import { PATH_PREFIX as V1_PATH_PREFIX, ingressDNSPath as v1IngressDNSPath, sandboxCustomDomainDNSPath as v1SandboxCustomDomainDNSPath, sandboxCustomDomainPath as v1SandboxCustomDomainPath, sandboxCustomDomainsPath as v1SandboxCustomDomainsPath, } from "./api/v1/paths.js";
3
3
  import { Image } from "../Image.js";
4
+ /**
5
+ * The request header that selects which of a credential's teams a request
6
+ * acts for. Keep in sync with pkg/api.HeaderTeam on the server.
7
+ */
8
+ export const TEAM_HEADER = "X-AerolVM-Team";
4
9
  const DEFAULT_API_VERSION = "v1";
5
10
  const PATH_PREFIXES = {
6
11
  v1: V1_PATH_PREFIX,
7
12
  };
13
+ function fromApiWhoAmI(w) {
14
+ const out = {
15
+ principal: { kind: w.principal.kind, id: w.principal.id },
16
+ role: w.role,
17
+ };
18
+ if (w.principal.name)
19
+ out.principal.name = w.principal.name;
20
+ if (w.team)
21
+ out.team = w.team;
22
+ if (w.expires_at)
23
+ out.expiresAt = w.expires_at;
24
+ return out;
25
+ }
26
+ function fromApiAPIToken(t) {
27
+ return {
28
+ id: t.id,
29
+ name: t.name,
30
+ role: t.role,
31
+ team: t.team,
32
+ createdBy: t.created_by,
33
+ createdAt: t.created_at,
34
+ expiresAt: t.expires_at,
35
+ expired: t.expired === true,
36
+ };
37
+ }
38
+ function fromApiEgressProfile(p) {
39
+ const out = {
40
+ name: p.name,
41
+ allowOut: p.allow_out ?? [],
42
+ generation: p.generation,
43
+ createdAt: p.created_at,
44
+ updatedAt: p.updated_at,
45
+ };
46
+ if (p.description)
47
+ out.description = p.description;
48
+ return out;
49
+ }
50
+ function toApiEgressRule({ inject, ...rule }) {
51
+ return inject ? { ...rule, inject: { header: inject.header, secret_ref: inject.secretRef } } : rule;
52
+ }
53
+ function fromApiEgressRule({ inject, ...rule }) {
54
+ return inject ? { ...rule, inject: { header: inject.header, secretRef: inject.secret_ref } } : rule;
55
+ }
8
56
  /** HTTP status codes the retry loop considers transient. */
9
- const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]);
57
+ // 421 Misdirected Request: an owner answered for a sandbox it doesn't hold
58
+ // (HTTP/2 connection coalescing, or a stale ingress route after failover).
59
+ // The server closes the connection, so the retry reconnects and the ingress
60
+ // re-routes it (plans/ingress-proxy-routing.md, review 2A).
61
+ const RETRYABLE_STATUS_CODES = new Set([421, 429, 502, 503, 504]);
62
+ // The subset a non-idempotent request (exec) may retry: 429 and 503 are the
63
+ // server turning the request away before doing any work. 421, 502 and 504 come
64
+ // from forwarding/proxy hops, and the status alone can't prove that no hop
65
+ // already handed the command to the sandbox, so exec surfaces them. They are
66
+ // also the statuses whose Retry-After paces clients: 429 when this caller is
67
+ // over its share, 503 when the node sheds load. A Retry-After on a 421, 502 or
68
+ // 504 is a hop's, not the server's, so those use the normal backoff.
69
+ const REFUSED_STATUS_CODES = new Set([429, 503]);
10
70
  /** Default retry settings when the caller doesn't supply a RetryConfig. */
11
71
  const DEFAULT_RETRY = {
12
72
  maxRetries: 3,
13
73
  baseDelayMs: 200,
14
74
  maxDelayMs: 5_000,
75
+ // An overloaded node asks for about a second, so the cap only bites on a
76
+ // long ask (maintenance, a misconfigured proxy), which should not hold a
77
+ // call for minutes per retry.
78
+ maxRetryAfterMs: 30_000,
15
79
  };
80
+ // IMF-fixdate, the HTTP-date form servers send ("Sun, 06 Nov 1994 08:49:37
81
+ // GMT"). Date.parse alone would also take "1" or a date with no zone, read in
82
+ // local time.
83
+ const IMF_FIXDATE = /^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
84
+ /**
85
+ * Reads a `Retry-After` value in either form RFC 9110 allows: delta-seconds
86
+ * (`"120"`) or an HTTP date. Returns the wait in milliseconds (0 for a date
87
+ * already past), or `undefined` for an absent or malformed value.
88
+ */
89
+ export function parseRetryAfter(value, nowMs) {
90
+ const trimmed = (value ?? "").trim();
91
+ if (/^[0-9]+$/.test(trimmed)) {
92
+ return Number(trimmed) * 1000;
93
+ }
94
+ if (!IMF_FIXDATE.test(trimmed)) {
95
+ return undefined;
96
+ }
97
+ const when = Date.parse(trimmed);
98
+ return Number.isNaN(when) ? undefined : Math.max(when - nowMs, 0);
99
+ }
100
+ /**
101
+ * How long {@link APIClient.request} waits before re-sending. `attempt`
102
+ * counts from 0, `retryAfter` is a 429/503's `Retry-After` header (null
103
+ * otherwise) and `random` is uniform in [0, 1).
104
+ *
105
+ * Without a usable header it is the exponential backoff ±25%. With one, it
106
+ * never retries sooner than the server asked (nor sooner than the backoff)
107
+ * and adds up to a fifth so clients told the same value spread out.
108
+ * `maxRetryAfterMs` is a hard ceiling: when the server asks for at least
109
+ * that long the client is early anyway, so the spread goes below it instead.
110
+ */
111
+ export function retryDelayMs(config, attempt, retryAfter, nowMs, random) {
112
+ const backoff = Math.min(config.baseDelayMs * 2 ** attempt, config.maxDelayMs);
113
+ const limit = config.maxRetryAfterMs;
114
+ const hint = limit > 0 ? parseRetryAfter(retryAfter, nowMs) : undefined;
115
+ if (hint === undefined) {
116
+ return backoff * (0.75 + 0.5 * random);
117
+ }
118
+ const target = Math.max(hint, backoff);
119
+ if (target >= limit) {
120
+ return limit - limit * 0.2 * random;
121
+ }
122
+ const upper = Math.min(target * 1.2, limit);
123
+ return target + (upper - target) * random;
124
+ }
16
125
  /**
17
126
  * Returns `true` when the thrown `error` looks like a transport-level failure
18
127
  * that vanishes on retry (connection reset, socket closed by peer, DNS
19
128
  * blip). `undici` and Node core use several `cause.code` values; we check
20
- * the full cause chain.
129
+ * the full cause chain. A reset, EPIPE, read/headers/body timeout or "other
130
+ * side closed" can fire after the server already received the request, so
131
+ * this classification is only safe for idempotent requests.
21
132
  */
22
133
  function isTransientTransportError(error) {
23
134
  const codes = new Set([
@@ -47,6 +158,45 @@ function isTransientTransportError(error) {
47
158
  }
48
159
  return false;
49
160
  }
161
+ /**
162
+ * Returns `true` only for transport failures that prove the request never
163
+ * left the client: the connection was refused, did not open before the
164
+ * connect timeout, or the host name did not resolve (transiently; ENOTFOUND
165
+ * is not retried for any request). Any other code in the cause chain means
166
+ * bytes may have reached the server, so the answer is `false`.
167
+ */
168
+ function isUnsentTransportError(error) {
169
+ let current = error;
170
+ for (let depth = 0; depth < 5 && current instanceof Error; depth++) {
171
+ const err = current;
172
+ switch (err.code) {
173
+ case undefined:
174
+ break;
175
+ case "ECONNREFUSED":
176
+ case "EAI_AGAIN":
177
+ case "UND_ERR_CONNECT_TIMEOUT":
178
+ return true;
179
+ case "ETIMEDOUT":
180
+ // Node uses ETIMEDOUT both for a connect that never completed and for
181
+ // an open socket that stopped answering; only the first is unsent.
182
+ return failedWhileConnecting(err);
183
+ default:
184
+ return false;
185
+ }
186
+ current = err.cause;
187
+ }
188
+ return false;
189
+ }
190
+ function failedWhileConnecting(err) {
191
+ if (err.syscall === "connect")
192
+ return true;
193
+ // With autoSelectFamily (Node's default), a host with several addresses
194
+ // fails with an AggregateError holding one error per connect attempt and no
195
+ // syscall of its own.
196
+ return (err instanceof AggregateError &&
197
+ err.errors.length > 0 &&
198
+ err.errors.every((inner) => inner?.syscall === "connect"));
199
+ }
50
200
  /**
51
201
  * Trim trailing slashes from a URL without a backtracking regex. The natural
52
202
  * `value.replace(/\/+$/, "")` is an anchored `+` quantifier — a polynomial
@@ -59,21 +209,37 @@ export function stripTrailingSlashes(value) {
59
209
  }
60
210
  return value.slice(0, end);
61
211
  }
62
- /** Sleep with ±25% jitter so concurrent callers don't thundering-herd. */
63
- function jitteredDelay(baseMs) {
64
- const jitter = 1 + (Math.random() - 0.5) * 0.5; // 0.75 – 1.25
65
- return new Promise((resolve) => setTimeout(resolve, Math.round(baseMs * jitter)));
212
+ /** Waits `ms`, or rejects with the signal's reason as soon as it aborts. */
213
+ function sleep(ms, signal) {
214
+ return new Promise((resolve, reject) => {
215
+ if (signal?.aborted) {
216
+ reject(signal.reason);
217
+ return;
218
+ }
219
+ const onAbort = () => {
220
+ clearTimeout(timer);
221
+ reject(signal?.reason);
222
+ };
223
+ const timer = setTimeout(() => {
224
+ signal?.removeEventListener("abort", onAbort);
225
+ resolve();
226
+ }, ms);
227
+ signal?.addEventListener("abort", onAbort, { once: true });
228
+ });
66
229
  }
67
230
  export class APIClient {
68
231
  baseURL;
69
232
  apiVersion;
70
233
  patToken;
234
+ team;
71
235
  fetchFn;
72
236
  versionPrefix;
73
237
  retryConfig;
238
+ signal;
74
239
  constructor(config) {
75
240
  this.baseURL = stripTrailingSlashes(config.baseURL);
76
241
  this.patToken = config.patToken ?? "";
242
+ this.team = (config.team ?? "").trim();
77
243
  this.fetchFn = config.fetch ?? fetch;
78
244
  this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;
79
245
  this.versionPrefix = PATH_PREFIXES[this.apiVersion];
@@ -81,7 +247,9 @@ export class APIClient {
81
247
  maxRetries: config.retry?.maxRetries ?? DEFAULT_RETRY.maxRetries,
82
248
  baseDelayMs: config.retry?.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
83
249
  maxDelayMs: config.retry?.maxDelayMs ?? DEFAULT_RETRY.maxDelayMs,
250
+ maxRetryAfterMs: config.retry?.maxRetryAfterMs ?? DEFAULT_RETRY.maxRetryAfterMs,
84
251
  };
252
+ this.signal = config.signal;
85
253
  }
86
254
  /**
87
255
  * Build a versioned API path. Pass the suffix beginning with "/" (e.g.
@@ -151,12 +319,62 @@ export class APIClient {
151
319
  return { ...options, image: result.image };
152
320
  }
153
321
  async list(options) {
154
- const path = this.versioned("/sandboxes") + buildTagQuery(options?.tags);
155
- const response = await this.doJSON("GET", path);
156
- return response.map((item) => this.wrap(item));
322
+ const items = [];
323
+ for await (const page of this.listPages(options)) {
324
+ items.push(...page);
325
+ }
326
+ return items;
327
+ }
328
+ /** Streams bounded cluster-list pages and never accumulates the full fleet. */
329
+ async *listPages(options) {
330
+ const basePath = this.versioned("/sandboxes") + buildSandboxListQuery(options);
331
+ let pageToken = "";
332
+ for (let page = 0; page < 100000; page++) {
333
+ const path = appendQueryParam(basePath, "page_token", pageToken);
334
+ const response = await this.request("GET", path);
335
+ if (!response.ok) {
336
+ throw await decodeError(response);
337
+ }
338
+ const partial = response.headers.get("X-Cluster-List-Partial");
339
+ const placementReady = response.headers.get("X-Cluster-List-Placement-Ready");
340
+ if (partial === "true" || placementReady === "false") {
341
+ throw new Error("incomplete cluster list");
342
+ }
343
+ const body = (await response.json());
344
+ yield (body ?? []).map((item) => this.wrap(item));
345
+ pageToken = (response.headers.get("X-Cluster-List-Next-Page-Token") ?? "").trim();
346
+ if (pageToken === "") {
347
+ return;
348
+ }
349
+ }
350
+ throw new Error("incomplete cluster list: exceeded max pages");
157
351
  }
158
- async get(id) {
159
- const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}`);
352
+ /**
353
+ * Looks up the caller's sandbox by name with one request. The reply is only
354
+ * trusted when it holds at most one sandbox and that sandbox carries the
355
+ * requested name: a server that predates `?name=` ignores the filter and
356
+ * returns an ordinary list page, and acting on its first row would target
357
+ * the wrong sandbox.
358
+ */
359
+ async getByName(name, options) {
360
+ const trimmed = name.trim();
361
+ if (trimmed === "") {
362
+ throw new Error("sandbox name is required");
363
+ }
364
+ const path = this.versioned("/sandboxes") + buildSandboxListQuery({ name: trimmed, includeEnv: options?.includeEnv });
365
+ const body = await this.doJSON("GET", path);
366
+ const items = body ?? [];
367
+ if (items.length === 0) {
368
+ return null;
369
+ }
370
+ if (items.length > 1 || items[0].name !== trimmed) {
371
+ throw new Error(`${this.baseURL} does not support sandbox name lookup; use the sandbox ID or upgrade the server`);
372
+ }
373
+ return this.wrap(items[0]);
374
+ }
375
+ async get(id, options) {
376
+ const q = buildIncludeEnvQuery(options?.includeEnv);
377
+ const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}${q}`);
160
378
  return this.wrap(response);
161
379
  }
162
380
  async start(id) {
@@ -200,6 +418,25 @@ export class APIClient {
200
418
  dockerfileContent: image.dockerfile,
201
419
  });
202
420
  }
421
+ async getSnapshot(name) {
422
+ return fromApiSandboxSnapshot(await this.doJSON("GET", this.versioned(`/snapshots/${encodeURIComponent(name)}`)));
423
+ }
424
+ async listSnapshots(options = {}) {
425
+ const params = new URLSearchParams();
426
+ if (options.limit)
427
+ params.set("limit", String(options.limit));
428
+ if (options.startingAfter)
429
+ params.set("starting_after", options.startingAfter);
430
+ const query = params.toString();
431
+ const response = await this.doJSON("GET", this.versioned(`/snapshots${query ? `?${query}` : ""}`));
432
+ return {
433
+ snapshots: (response.snapshots ?? []).map(fromApiSandboxSnapshot),
434
+ hasMore: response.has_more ?? false,
435
+ };
436
+ }
437
+ async deleteSnapshot(name) {
438
+ await this.doJSON("DELETE", this.versioned(`/snapshots/${encodeURIComponent(name)}`));
439
+ }
203
440
  async destroy(id) {
204
441
  await this.doJSON("DELETE", `${this.versionPrefix}/sandboxes/${id}`);
205
442
  }
@@ -212,7 +449,8 @@ export class APIClient {
212
449
  return this.wrap(response);
213
450
  }
214
451
  async exec(id, request) {
215
- const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/process/execute`, toApiExecRequest(request));
452
+ // Not idempotent: a re-sent exec runs the caller's command a second time.
453
+ const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/process/execute`, toApiExecRequest(request), { idempotent: false });
216
454
  return fromApiExecResult(response);
217
455
  }
218
456
  execStream(id, options) {
@@ -328,6 +566,22 @@ export class APIClient {
328
566
  const response = await this.doJSON("GET", "/health");
329
567
  return fromApiHealthStatus(response);
330
568
  }
569
+ async checkNetworkPolicy(options) {
570
+ const response = await this.doJSON("POST", this.versioned("/network/policy/check"), {
571
+ network_block_all: options.networkBlockAll,
572
+ network_allow_out: options.networkAllowOut,
573
+ network_deny_out: options.networkDenyOut,
574
+ destination: options.destination,
575
+ });
576
+ const result = {
577
+ allowed: response.allowed,
578
+ matchedRule: response.matched_rule ?? "",
579
+ defaultVerdict: response.default_verdict,
580
+ };
581
+ if (response.outside_ceiling)
582
+ result.outsideCeiling = response.outside_ceiling;
583
+ return result;
584
+ }
331
585
  async mounts(id) {
332
586
  const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/mounts`);
333
587
  return response.mounts.map(fromApiMountSpecRedacted);
@@ -340,10 +594,86 @@ export class APIClient {
340
594
  const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/network/usage`);
341
595
  return fromApiNetworkUsage(response);
342
596
  }
597
+ async getAudit(id, options = {}) {
598
+ const params = new URLSearchParams();
599
+ if (options.kind)
600
+ params.set("kind", options.kind);
601
+ if (options.limit !== undefined)
602
+ params.set("limit", String(options.limit));
603
+ if (options.cursor)
604
+ params.set("cursor", options.cursor);
605
+ if (options.incarnationID)
606
+ params.set("incarnation_id", options.incarnationID);
607
+ const query = params.toString();
608
+ const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/audit${query ? `?${query}` : ""}`);
609
+ return fromApiAuditPage(response);
610
+ }
343
611
  async setNetworkLimits(id, options) {
344
612
  const response = await this.doJSON("PATCH", `${this.versionPrefix}/sandboxes/${id}/network/limits`, toApiSetNetworkLimitsOptions(options));
345
613
  return fromApiNetworkUsage(response);
346
614
  }
615
+ async setNetworkPolicy(id, options) {
616
+ const response = await this.doJSON("PUT", `${this.versionPrefix}/sandboxes/${id}/network/policy`, {
617
+ network_block_all: options.networkBlockAll ?? false,
618
+ network_allow_out: options.networkAllowOut ?? [],
619
+ network_deny_out: options.networkDenyOut ?? [],
620
+ egress_profiles: options.egressProfiles ?? [],
621
+ network_egress_mode: options.networkEgressMode,
622
+ network_egress_rules: options.networkEgressRules?.map(toApiEgressRule),
623
+ });
624
+ const policy = {
625
+ networkBlockAll: response.network_block_all,
626
+ networkAllowOut: response.network_allow_out ?? [],
627
+ networkDenyOut: response.network_deny_out ?? [],
628
+ egressProfiles: response.egress_profiles ?? [],
629
+ networkEgressMode: response.network_egress_mode ?? "enforce",
630
+ networkEgressRules: (response.network_egress_rules ?? []).map(fromApiEgressRule),
631
+ effectiveHostnameCount: response.effective_hostname_count ?? 0,
632
+ };
633
+ if (response.egress_status)
634
+ policy.egressStatus = response.egress_status;
635
+ return policy;
636
+ }
637
+ async getNetworkLearned(id) {
638
+ const r = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/network/learned`);
639
+ const learned = {
640
+ mode: r.mode,
641
+ truncated: r.truncated,
642
+ entries: (r.entries ?? []).map((e) => ({ host: e.host, ports: e.ports ?? [], firstSeen: e.first_seen, lastSeen: e.last_seen, hits: e.hits })),
643
+ cidrs: r.cidrs ?? [],
644
+ suggestedAllowOut: r.suggested_allow_out ?? [],
645
+ };
646
+ if (r.suggested_profile) {
647
+ learned.suggestedProfile = { allowOut: r.suggested_profile.allow_out ?? [], description: r.suggested_profile.description };
648
+ }
649
+ return learned;
650
+ }
651
+ async putEgressProfile(name, options) {
652
+ const response = await this.doJSON("PUT", this.versioned(`/egress-profiles/${encodeURIComponent(name)}`), {
653
+ allow_out: options.allowOut,
654
+ description: options.description,
655
+ });
656
+ return fromApiEgressProfile(response);
657
+ }
658
+ async getEgressProfile(name) {
659
+ return fromApiEgressProfile(await this.doJSON("GET", this.versioned(`/egress-profiles/${encodeURIComponent(name)}`)));
660
+ }
661
+ async listEgressProfiles(options = {}) {
662
+ const params = new URLSearchParams();
663
+ if (options.cursor)
664
+ params.set("cursor", options.cursor);
665
+ if (options.limit)
666
+ params.set("limit", String(options.limit));
667
+ const query = params.toString();
668
+ const response = await this.doJSON("GET", this.versioned(`/egress-profiles${query ? `?${query}` : ""}`));
669
+ const list = { profiles: (response.profiles ?? []).map(fromApiEgressProfile) };
670
+ if (response.next_cursor)
671
+ list.nextCursor = response.next_cursor;
672
+ return list;
673
+ }
674
+ async deleteEgressProfile(name) {
675
+ await this.doJSON("DELETE", this.versioned(`/egress-profiles/${encodeURIComponent(name)}`));
676
+ }
347
677
  async createTemplate(options) {
348
678
  const response = await this.doJSON("POST", this.versioned("/templates"), {
349
679
  id: options.id,
@@ -407,10 +737,33 @@ export class APIClient {
407
737
  const response = await this.doJSON("POST", `${this.versionPrefix}/templates/${id}/rebuild`);
408
738
  return fromApiTemplate(response);
409
739
  }
740
+ async whoami() {
741
+ return fromApiWhoAmI(await this.doJSON("GET", this.versioned("/whoami")));
742
+ }
743
+ async createAPIToken(options) {
744
+ const response = await this.doJSON("POST", this.versioned("/tokens"), {
745
+ name: options.name,
746
+ role: options.role,
747
+ team: options.team,
748
+ expires_in: options.expiresIn,
749
+ });
750
+ return { ...fromApiAPIToken(response), token: response.token };
751
+ }
752
+ async listAPITokens() {
753
+ const response = await this.doJSON("GET", this.versioned("/tokens"));
754
+ return { tokens: (response.tokens ?? []).map(fromApiAPIToken) };
755
+ }
756
+ async revokeAPIToken(id) {
757
+ const trimmed = id.trim();
758
+ if (trimmed === "") {
759
+ throw new Error("token id is required");
760
+ }
761
+ await this.doJSON("DELETE", this.versioned(`/tokens/${encodeURIComponent(trimmed)}`));
762
+ }
410
763
  wrap(sandbox) {
411
764
  return new SandboxResource(this, fromApiSandbox(sandbox));
412
765
  }
413
- async doJSON(method, path, body) {
766
+ async doJSON(method, path, body, options) {
414
767
  const init = {};
415
768
  if (body !== undefined) {
416
769
  init.body = JSON.stringify(body);
@@ -418,7 +771,7 @@ export class APIClient {
418
771
  "Content-Type": "application/json",
419
772
  };
420
773
  }
421
- const response = await this.request(method, path, init);
774
+ const response = await this.request(method, path, init, options);
422
775
  if (!response.ok) {
423
776
  throw await decodeError(response);
424
777
  }
@@ -437,46 +790,59 @@ export class APIClient {
437
790
  /**
438
791
  * Low-level HTTP request with automatic retry for transient failures.
439
792
  *
440
- * Transport-level errors (socket closed, connection reset, DNS blip) are
441
- * always retried regardless of HTTP method — the request never reached the
442
- * server so there is no idempotency concern.
793
+ * Transport-level errors (socket closed, connection reset, timeouts, DNS
794
+ * blip) and HTTP 421/429/502/503/504 are retried regardless of HTTP method.
795
+ * Several of those failures can happen after the server received the
796
+ * request, so a retry may deliver it twice; that is safe only because every
797
+ * mutating endpoint in the daemon is designed for idempotent retry (e.g.
798
+ * INSERT OR IGNORE + disambiguation).
799
+ *
800
+ * A request passed `{ idempotent: false }` (exec) retries only transport
801
+ * failures that prove the request never left the client and HTTP 429/503;
802
+ * everything else is surfaced to the caller rather than risk running the
803
+ * command twice.
443
804
  *
444
- * HTTP-level retryable codes (429, 502, 503, 504) are retried for ALL
445
- * methods because every mutating endpoint in the daemon is already
446
- * designed for idempotent retry (e.g. INSERT OR IGNORE + disambiguation).
805
+ * A 429 or 503 waits as long as its `Retry-After` asks (see
806
+ * {@link retryDelayMs}). The client's `signal` ends the request and any
807
+ * wait.
447
808
  */
448
- async request(method, path, init = {}) {
809
+ async request(method, path, init = {}, options = {}) {
449
810
  const headers = new Headers(init.headers);
450
811
  if (this.patToken !== "") {
451
812
  headers.set("Authorization", `Bearer ${this.patToken}`);
452
813
  }
814
+ if (this.team !== "") {
815
+ headers.set(TEAM_HEADER, this.team);
816
+ }
453
817
  const url = `${this.baseURL}${path}`;
454
- const requestInit = { ...init, method, headers };
455
- const { maxRetries, baseDelayMs, maxDelayMs } = this.retryConfig;
456
- let lastError;
457
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
818
+ const signal = init.signal ?? this.signal;
819
+ const requestInit = { ...init, method, headers, ...(signal ? { signal } : {}) };
820
+ const { maxRetries } = this.retryConfig;
821
+ const idempotent = options.idempotent ?? true;
822
+ const retryableStatusCodes = idempotent ? RETRYABLE_STATUS_CODES : REFUSED_STATUS_CODES;
823
+ const isRetryableError = idempotent ? isTransientTransportError : isUnsentTransportError;
824
+ for (let attempt = 0;; attempt++) {
825
+ let retryAfter = null;
458
826
  try {
459
827
  const response = await this.fetchFn(url, requestInit);
460
- // Retry on transient HTTP status codes.
461
- if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxRetries) {
462
- const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
463
- await jitteredDelay(delay);
464
- continue;
828
+ if (!retryableStatusCodes.has(response.status) || attempt >= maxRetries) {
829
+ return response;
830
+ }
831
+ if (REFUSED_STATUS_CODES.has(response.status)) {
832
+ retryAfter = response.headers.get("retry-after");
465
833
  }
466
- return response;
834
+ // Free the connection instead of holding it through the wait.
835
+ response.body?.cancel().catch(() => undefined);
467
836
  }
468
837
  catch (error) {
469
- lastError = error;
470
- // Only retry transport-level failures; anything else propagates.
471
- if (!isTransientTransportError(error) || attempt >= maxRetries) {
838
+ // Only retry transport-level failures; anything else propagates. An
839
+ // aborted signal surfaces whatever fetch threw for it.
840
+ if (signal?.aborted || !isRetryableError(error) || attempt >= maxRetries) {
472
841
  throw error;
473
842
  }
474
- const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
475
- await jitteredDelay(delay);
476
843
  }
844
+ await sleep(retryDelayMs(this.retryConfig, attempt, retryAfter, Date.now(), Math.random()), signal);
477
845
  }
478
- // Should be unreachable — the loop always throws or returns.
479
- throw lastError;
480
846
  }
481
847
  }
482
848
  export class SandboxResource {
@@ -594,6 +960,36 @@ export class SandboxResource {
594
960
  async setNetworkLimits(options) {
595
961
  return this.client.setNetworkLimits(this.id, options);
596
962
  }
963
+ /**
964
+ * Replaces this sandbox's egress policy while it runs. The call returns
965
+ * once the new policy is enforced; sending the same policy again is a
966
+ * no-op, so it is safe to retry.
967
+ */
968
+ async setNetworkPolicy(options) {
969
+ const policy = await this.client.setNetworkPolicy(this.id, options);
970
+ this.networkBlockAll = policy.networkBlockAll;
971
+ this.egressStatus = policy.egressStatus;
972
+ this.egressProfiles = policy.egressProfiles.length ? policy.egressProfiles : undefined;
973
+ this.networkEgressMode = policy.networkEgressMode === "learn" ? "learn" : undefined;
974
+ this.networkEgressRules = policy.networkEgressRules.length ? policy.networkEgressRules : undefined;
975
+ return policy;
976
+ }
977
+ /**
978
+ * Reads what this sandbox reached in learn mode and the allow list that
979
+ * would have allowed it. A recording stays readable after the sandbox
980
+ * switches to enforce, until it is destroyed.
981
+ */
982
+ async learned() {
983
+ return this.client.getNetworkLearned(this.id);
984
+ }
985
+ /**
986
+ * Reads this sandbox's audit log: outbound connections and egress denials
987
+ * (`kind: "egress"`), and secret reads. Denials carry `result: "failure"`
988
+ * and the policy `reason`.
989
+ */
990
+ async audit(options) {
991
+ return this.client.getAudit(this.id, options);
992
+ }
597
993
  toJSON() {
598
994
  return cloneSandbox(this);
599
995
  }
@@ -603,6 +999,8 @@ export class SandboxResource {
603
999
  }
604
1000
  function toApiCreateOptions(options) {
605
1001
  return {
1002
+ name: options.name,
1003
+ tags: options.tags,
606
1004
  image: options.image,
607
1005
  cpu: options.cpu,
608
1006
  memory_mb: options.memoryMB,
@@ -612,6 +1010,9 @@ function toApiCreateOptions(options) {
612
1010
  network_block_all: options.networkBlockAll,
613
1011
  network_allow_out: options.networkAllowOut,
614
1012
  network_deny_out: options.networkDenyOut,
1013
+ egress_profiles: options.egressProfiles,
1014
+ network_egress_mode: options.networkEgressMode,
1015
+ network_egress_rules: options.networkEgressRules?.map(toApiEgressRule),
615
1016
  allow_public_traffic: options.allowPublicTraffic,
616
1017
  mask_request_host: options.maskRequestHost,
617
1018
  network_bytes_in_limit: options.networkBytesInLimit,
@@ -623,7 +1024,19 @@ function toApiCreateOptions(options) {
623
1024
  lifecycle: options.lifecycle ? toApiLifecycle(options.lifecycle) : undefined,
624
1025
  failover: options.failover ? toApiFailover(options.failover) : undefined,
625
1026
  runtime: options.runtime,
1027
+ durability: options.durability,
1028
+ module_ref: options.moduleRef,
1029
+ tenant_id: options.tenantId,
626
1030
  custom_domains: options.customDomains,
1031
+ gpus: options.gpus ? toApiGPUOptions(options.gpus) : undefined,
1032
+ template_id: options.templateId,
1033
+ };
1034
+ }
1035
+ function toApiGPUOptions(gpus) {
1036
+ return {
1037
+ vendor: gpus.vendor,
1038
+ count: gpus.count,
1039
+ device_ids: gpus.deviceIDs,
627
1040
  };
628
1041
  }
629
1042
  function toApiRegisterSnapshotOptions(options) {
@@ -684,6 +1097,8 @@ function toApiFailover(failover) {
684
1097
  function fromApiSandbox(sandbox) {
685
1098
  return {
686
1099
  id: sandbox.id,
1100
+ name: sandbox.name || undefined,
1101
+ tags: sandbox.tags,
687
1102
  image: sandbox.image,
688
1103
  status: sandbox.status,
689
1104
  publicURL: sandbox.public_url,
@@ -695,6 +1110,11 @@ function fromApiSandbox(sandbox) {
695
1110
  osUser: sandbox.os_user,
696
1111
  env: sandbox.env,
697
1112
  networkBlockAll: sandbox.network_block_all,
1113
+ egressStatus: sandbox.egress_status || undefined,
1114
+ egressProfiles: sandbox.egress_profiles?.length ? sandbox.egress_profiles : undefined,
1115
+ egressProfilesApplied: sandbox.egress_profiles_applied?.length ? sandbox.egress_profiles_applied : undefined,
1116
+ networkEgressMode: sandbox.network_egress_mode || undefined,
1117
+ networkEgressRules: sandbox.network_egress_rules?.length ? sandbox.network_egress_rules.map(fromApiEgressRule) : undefined,
698
1118
  toolboxEnabled: sandbox.toolbox_enabled,
699
1119
  sshPublicKey: sandbox.ssh_public_key,
700
1120
  exposedPorts: sandbox.exposed_ports?.map(fromApiExposedPort),
@@ -706,13 +1126,22 @@ function fromApiSandbox(sandbox) {
706
1126
  lifecycle: fromApiLifecycle(sandbox.lifecycle),
707
1127
  failover: fromApiFailover(sandbox.failover),
708
1128
  runtime: normalizeRuntime(sandbox.runtime),
1129
+ durability: sandbox.durability,
1130
+ moduleRef: sandbox.module_ref,
1131
+ moduleDigest: sandbox.module_digest,
1132
+ tenantId: sandbox.tenant_id,
709
1133
  };
710
1134
  }
711
1135
  // normalizeRuntime narrows the wire-level string to the union we expose, while
712
1136
  // tolerating older sandboxd versions that don't send the field at all (treat
713
1137
  // as "" — i.e. host default at start time).
714
1138
  function normalizeRuntime(value) {
715
- if (value === "docker" || value === "gvisor" || value === "kata") {
1139
+ if (value === "docker" ||
1140
+ value === "gvisor" ||
1141
+ value === "kata" ||
1142
+ value === "firecracker" ||
1143
+ value === "wasm" ||
1144
+ value === "isolate") {
716
1145
  return value;
717
1146
  }
718
1147
  return "";
@@ -874,6 +1303,31 @@ function fromApiMountSpecRedacted(mount) {
874
1303
  hasCredentials: mount.has_credentials,
875
1304
  };
876
1305
  }
1306
+ function fromApiAuditPage(page) {
1307
+ const out = {
1308
+ events: (page.events ?? []).map((e) => ({
1309
+ time: e.time,
1310
+ kind: e.kind,
1311
+ result: e.result,
1312
+ reason: e.reason,
1313
+ destination: e.destination,
1314
+ network: e.network,
1315
+ actor: e.actor,
1316
+ ref: e.ref,
1317
+ eventID: e.event_id,
1318
+ incarnationID: e.incarnation_id,
1319
+ dropped: e.dropped,
1320
+ })),
1321
+ coverage: {
1322
+ answered: page.coverage?.answered ?? [],
1323
+ missing: page.coverage?.missing ?? [],
1324
+ partial: page.coverage?.partial ?? false,
1325
+ },
1326
+ };
1327
+ if (page.next_cursor)
1328
+ out.nextCursor = page.next_cursor;
1329
+ return out;
1330
+ }
877
1331
  function fromApiNetworkUsage(usage) {
878
1332
  return {
879
1333
  sandboxID: usage.sandbox_id,
@@ -933,10 +1387,32 @@ function buildTagQuery(tags) {
933
1387
  const parts = entries.map(([key, value]) => `tag.${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
934
1388
  return "?" + parts.join("&");
935
1389
  }
1390
+ function buildIncludeEnvQuery(includeEnv) {
1391
+ return includeEnv ? "?include_env=true" : "";
1392
+ }
1393
+ function buildSandboxListQuery(options) {
1394
+ let query = buildTagQuery(options?.tags);
1395
+ if (options?.includeEnv) {
1396
+ query = appendQueryParam(query, "include_env", "true");
1397
+ }
1398
+ const name = options?.name?.trim();
1399
+ if (name) {
1400
+ query = appendQueryParam(query, "name", name);
1401
+ }
1402
+ return query;
1403
+ }
1404
+ function appendQueryParam(path, key, value) {
1405
+ if (!value) {
1406
+ return path;
1407
+ }
1408
+ const sep = path.includes("?") ? "&" : "?";
1409
+ return `${path}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
1410
+ }
936
1411
  function cloneSandbox(sandbox) {
937
1412
  return {
938
1413
  ...sandbox,
939
1414
  env: sandbox.env ? { ...sandbox.env } : undefined,
1415
+ tags: sandbox.tags ? { ...sandbox.tags } : undefined,
940
1416
  exposedPorts: sandbox.exposedPorts?.map((port) => ({ ...port })),
941
1417
  containerCommand: sandbox.containerCommand ? [...sandbox.containerCommand] : undefined,
942
1418
  lifecycle: { ...sandbox.lifecycle },