@aerol-ai/aerolvm-sdk 0.6.4 → 0.8.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/dist/MicroVM.d.ts CHANGED
@@ -1,11 +1,40 @@
1
1
  import { type APIVersion, type RetryConfig } from "./internal/client.js";
2
2
  import { Sandbox } from "./Sandbox.js";
3
3
  import { Image } from "./Image.js";
4
- import type { BuildImageOptions, BuildImageResult, CloneGeneration, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, ExecStreamHandle, ExecStreamOptions, HealthStatus, EgressProfile, EgressProfileList, EgressProfileOptions, ListEgressProfilesOptions, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, RegisterSnapshotOptions, ResizeOptions, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule } from "./types.js";
4
+ import type { APITokenList, BuildImageOptions, BuildImageResult, CloneGeneration, CreateAPITokenOptions, CreatedAPIToken, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, ExecStreamHandle, ExecStreamOptions, HealthStatus, EgressProfile, EgressProfileList, EgressProfileOptions, ListEgressProfilesOptions, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, RegisterSnapshotOptions, ListSnapshotsOptions, SnapshotList, ResizeOptions, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule, WhoAmI } from "./types.js";
5
5
  type FetchLike = typeof fetch;
6
+ /** Scoped API token calls, reached as `microvm.tokens`. */
7
+ export interface Tokens {
8
+ /**
9
+ * Mint a token (admin only). The result's `token` is the secret, returned
10
+ * this once and never again. A 409 means the name is taken, possibly by an
11
+ * earlier attempt whose reply was lost; that secret cannot be recovered,
12
+ * so revoke the token and create it again.
13
+ */
14
+ create(options: CreateAPITokenOptions): Promise<CreatedAPIToken>;
15
+ /** List every token (auditor or admin); expired ones stay listed for 30 days. */
16
+ list(): Promise<APITokenList>;
17
+ /** Revoke a token by id (admin only). Revoking one already gone succeeds. */
18
+ revoke(id: string): Promise<void>;
19
+ }
6
20
  export interface MicroVMConfig {
21
+ /**
22
+ * The bearer every request carries: the operator PAT, an `avm_` API token
23
+ * or an OIDC ID token. Defaults to `SB_PAT_TOKEN`, then its alias
24
+ * `SB_API_TOKEN`; when both are set, `SB_PAT_TOKEN` wins.
25
+ */
7
26
  patToken?: string;
8
27
  apiUrl?: string;
28
+ /**
29
+ * The team requests act for, sent as the `X-AerolVM-Team` header on every
30
+ * HTTP request. Defaults to `SB_TEAM`; with neither, no header is sent and
31
+ * the server uses the credential's first matching binding. The header only
32
+ * chooses among teams the credential is already bound to (a team it does
33
+ * not hold is refused with 403), and the operator PAT ignores it.
34
+ * WebSocket streams (`execStream`, `attachSession`) cannot carry custom
35
+ * headers from a browser or Node, so they go without it.
36
+ */
37
+ team?: string;
9
38
  fetch?: FetchLike;
10
39
  /**
11
40
  * Wire version of the sandbox daemon API to call. Defaults to the SDK's
@@ -17,14 +46,27 @@ export interface MicroVMConfig {
17
46
  /**
18
47
  * Retry policy for transient transport errors (socket closed, connection
19
48
  * reset) and retryable HTTP status codes (421, 429, 502, 503, 504). The SDK
20
- * retries up to 3 times with exponential backoff by default. Pass
21
- * `{ maxRetries: 0 }` to disable.
49
+ * retries up to 3 times with exponential backoff by default. `exec` retries
50
+ * only failures that prove the command never reached the server
51
+ * (connection refused, connect timeout, DNS) and HTTP 429/503, so a command
52
+ * never runs twice. Pass `{ maxRetries: 0 }` to disable. A 429 or 503
53
+ * waits as long as the server's `Retry-After` asks, up to
54
+ * `retry.maxRetryAfterMs` (30 s by default).
22
55
  */
23
56
  retry?: RetryConfig;
57
+ /**
58
+ * Cancels the client's HTTP requests: aborting it ends the request in
59
+ * flight and any retry wait, so a call never sits out a long
60
+ * `Retry-After` once aborted. Pass `AbortSignal.timeout(ms)` to bound
61
+ * how long the client may run. WebSocket streams are not affected.
62
+ */
63
+ signal?: AbortSignal;
24
64
  }
25
65
  export declare class MicroVM {
26
66
  readonly apiUrl: string;
27
67
  readonly patToken: string;
68
+ /** The team sent as `X-AerolVM-Team`, or `""` when none is. */
69
+ readonly team: string;
28
70
  private readonly client;
29
71
  constructor(config?: MicroVMConfig);
30
72
  create(options: CreateOptions): Promise<Sandbox>;
@@ -46,6 +88,12 @@ export declare class MicroVM {
46
88
  createSnapshot(id: string, name: string): Promise<SandboxSnapshot>;
47
89
  registerSnapshot(options: RegisterSnapshotOptions): Promise<SandboxSnapshot>;
48
90
  registerSnapshotFromImage(name: string, image: Image, options?: Omit<RegisterSnapshotOptions, "name" | "image" | "dockerfileContent">): Promise<SandboxSnapshot>;
91
+ /** Look a snapshot up by name. In a cluster the request is routed to the node holding the image. */
92
+ getSnapshot(name: string): Promise<SandboxSnapshot>;
93
+ /** List the caller's snapshots, one name-ordered page at a time. */
94
+ listSnapshots(options?: ListSnapshotsOptions): Promise<SnapshotList>;
95
+ /** Delete a snapshot by name (idempotent 404 if it does not exist). */
96
+ deleteSnapshot(name: string): Promise<void>;
49
97
  destroy(id: string): Promise<void>;
50
98
  resize(id: string, options: ResizeOptions): Promise<Sandbox>;
51
99
  updateLifecycle(id: string, lifecycle: Lifecycle): Promise<Sandbox>;
@@ -143,6 +191,17 @@ export declare class MicroVM {
143
191
  get dns(): {
144
192
  target(): Promise<IngressTarget>;
145
193
  };
194
+ /**
195
+ * Who the server says the caller is: principal, role, team and when the
196
+ * credential expires. Every valid credential may ask.
197
+ */
198
+ whoami(): Promise<WhoAmI>;
199
+ /**
200
+ * Scoped API tokens (`avm_…`) for services and CI, as
201
+ * `microvm.tokens.create/list/revoke`. Same namespaced shape as
202
+ * {@link MicroVM.dns}.
203
+ */
204
+ get tokens(): Tokens;
146
205
  private wrap;
147
206
  }
148
207
  export {};
package/dist/MicroVM.js CHANGED
@@ -1,25 +1,31 @@
1
1
  import { APIClient, stripTrailingSlashes } from "./internal/client.js";
2
2
  import { Sandbox } from "./Sandbox.js";
3
3
  const defaultAPIURL = "http://127.0.0.1:21212";
4
- const authRequiredErrorMessage = "PAT token is required. Set patToken or SB_PAT_TOKEN.";
4
+ const authRequiredErrorMessage = "PAT token is required. Set patToken, SB_PAT_TOKEN or SB_API_TOKEN.";
5
5
  export class MicroVM {
6
6
  apiUrl;
7
7
  patToken;
8
+ /** The team sent as `X-AerolVM-Team`, or `""` when none is. */
9
+ team;
8
10
  client;
9
11
  constructor(config = {}) {
10
- const patToken = config.patToken ?? readEnv("SB_PAT_TOKEN") ?? "";
12
+ const patToken = config.patToken ?? readEnv("SB_PAT_TOKEN") ?? readEnv("SB_API_TOKEN") ?? "";
11
13
  const apiUrl = normalizeURL(config.apiUrl ?? readEnv("SB_API_URL") ?? defaultAPIURL);
14
+ const team = (config.team ?? readEnv("SB_TEAM") ?? "").trim();
12
15
  if (patToken === "") {
13
16
  throw new Error(authRequiredErrorMessage);
14
17
  }
15
18
  this.apiUrl = apiUrl;
16
19
  this.patToken = patToken;
20
+ this.team = team;
17
21
  this.client = new APIClient({
18
22
  baseURL: apiUrl,
19
23
  patToken,
24
+ team,
20
25
  fetch: config.fetch,
21
26
  apiVersion: config.apiVersion,
22
27
  retry: config.retry,
28
+ signal: config.signal,
23
29
  });
24
30
  }
25
31
  async create(options) {
@@ -69,6 +75,18 @@ export class MicroVM {
69
75
  async registerSnapshotFromImage(name, image, options = {}) {
70
76
  return this.client.registerSnapshotFromImage(name, image, options);
71
77
  }
78
+ /** Look a snapshot up by name. In a cluster the request is routed to the node holding the image. */
79
+ async getSnapshot(name) {
80
+ return this.client.getSnapshot(name);
81
+ }
82
+ /** List the caller's snapshots, one name-ordered page at a time. */
83
+ async listSnapshots(options = {}) {
84
+ return this.client.listSnapshots(options);
85
+ }
86
+ /** Delete a snapshot by name (idempotent 404 if it does not exist). */
87
+ async deleteSnapshot(name) {
88
+ return this.client.deleteSnapshot(name);
89
+ }
72
90
  async destroy(id) {
73
91
  await this.client.destroy(id);
74
92
  }
@@ -237,6 +255,26 @@ export class MicroVM {
237
255
  target: () => client.ingressDNS(),
238
256
  };
239
257
  }
258
+ /**
259
+ * Who the server says the caller is: principal, role, team and when the
260
+ * credential expires. Every valid credential may ask.
261
+ */
262
+ async whoami() {
263
+ return this.client.whoami();
264
+ }
265
+ /**
266
+ * Scoped API tokens (`avm_…`) for services and CI, as
267
+ * `microvm.tokens.create/list/revoke`. Same namespaced shape as
268
+ * {@link MicroVM.dns}.
269
+ */
270
+ get tokens() {
271
+ const client = this.client;
272
+ return {
273
+ create: (options) => client.createAPIToken(options),
274
+ list: () => client.listAPITokens(),
275
+ revoke: (id) => client.revokeAPIToken(id),
276
+ };
277
+ }
240
278
  wrap(sandbox) {
241
279
  return new Sandbox(this.client, sandbox);
242
280
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * An HTTP error response from the AerolVM server. `message` is the server's
3
+ * message, or `request failed with status N` when the body carries none (a
4
+ * proxy's HTML error page, say): the same text the SDK threw before this
5
+ * class existed, so code that printed or matched it sees no change.
6
+ *
7
+ * Branch on `code`, not on `message`: the code is a stable identifier, the
8
+ * message is wording for a person.
9
+ */
10
+ export declare class MicroVMApiError extends Error {
11
+ /** The HTTP status of the final attempt, such as 422 or 503. */
12
+ readonly status: number;
13
+ /**
14
+ * The server's stable error code, such as `"idempotency_key_reused"` or
15
+ * `"overloaded"`. Undefined when the response has none: many errors are
16
+ * only ever shown to a person, and a proxy's error page has no code.
17
+ */
18
+ readonly code: string | undefined;
19
+ /**
20
+ * How long the response's `Retry-After` header asked the caller to wait,
21
+ * in milliseconds. Undefined without a usable header.
22
+ */
23
+ readonly retryAfterMs: number | undefined;
24
+ constructor(status: number, message: string, options?: {
25
+ code?: string;
26
+ retryAfterMs?: number;
27
+ });
28
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * An HTTP error response from the AerolVM server. `message` is the server's
3
+ * message, or `request failed with status N` when the body carries none (a
4
+ * proxy's HTML error page, say): the same text the SDK threw before this
5
+ * class existed, so code that printed or matched it sees no change.
6
+ *
7
+ * Branch on `code`, not on `message`: the code is a stable identifier, the
8
+ * message is wording for a person.
9
+ */
10
+ export class MicroVMApiError extends Error {
11
+ /** The HTTP status of the final attempt, such as 422 or 503. */
12
+ status;
13
+ /**
14
+ * The server's stable error code, such as `"idempotency_key_reused"` or
15
+ * `"overloaded"`. Undefined when the response has none: many errors are
16
+ * only ever shown to a person, and a proxy's error page has no code.
17
+ */
18
+ code;
19
+ /**
20
+ * How long the response's `Retry-After` header asked the caller to wait,
21
+ * in milliseconds. Undefined without a usable header.
22
+ */
23
+ retryAfterMs;
24
+ constructor(status, message, options = {}) {
25
+ super(message);
26
+ this.name = "MicroVMApiError";
27
+ this.status = status;
28
+ this.code = options.code;
29
+ this.retryAfterMs = options.retryAfterMs;
30
+ }
31
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { MicroVM } from "./MicroVM.js";
2
2
  export { Sandbox } from "./Sandbox.js";
3
3
  export { Image } from "./Image.js";
4
- export type { MicroVMConfig } from "./MicroVM.js";
4
+ export { MicroVMApiError } from "./errors.js";
5
+ export type { MicroVMConfig, Tokens } from "./MicroVM.js";
5
6
  export type { RetryConfig } from "./internal/client.js";
6
- export type { AddCustomDomainOptions, AuditEvent, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressInject, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkLearnedEntry, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, BinaryLike, BuildImageOptions, BuildImagePushOptions, BuildImageResult, CloneGeneration, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, CustomDomain, CustomDomainDNSRecords, CustomDomainStatus, DNSRecord, DNSRecordType, ExecRequest, ExecResult, ExecExitInfo, ExposedPort, ExposePortOptions, ExposeProtocol, ExposeResult, Failover, FailoverPolicy, GetOptions, HealthStatus, IngressTarget, IngressTargetSource, Lifecycle, ListOptions, MountSpec, MountSpecRedacted, MountType, PlatformVolumeMount, RegisterSnapshotOptions, RegistryAuth, ResizeOptions, Sandbox as SandboxData, SandboxSnapshot, SandboxStatus, Session, SessionAttachHandle, SessionAttachOptions, SessionStatus, Template, TemplatePushState, TemplateStatus, WasmModule, WasmModuleStatus, PushWasmModuleOptions, PushWasmModuleResult, UpdateLifecycleOptions, } from "./types.js";
7
+ export type { AddCustomDomainOptions, APIToken, APITokenList, CreateAPITokenOptions, CreatedAPIToken, WhoAmI, WhoAmIPrincipal, AuditEvent, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressInject, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkLearnedEntry, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, BinaryLike, BuildImageOptions, BuildImagePushOptions, BuildImageResult, CloneGeneration, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, CustomDomain, CustomDomainDNSRecords, CustomDomainStatus, DNSRecord, DNSRecordType, ExecRequest, ExecResult, ExecExitInfo, ExposedPort, ExposePortOptions, ExposeProtocol, ExposeResult, Failover, FailoverPolicy, GetOptions, HealthStatus, IngressTarget, IngressTargetSource, Lifecycle, ListOptions, MountSpec, MountSpecRedacted, MountType, PlatformVolumeMount, RegisterSnapshotOptions, ListSnapshotsOptions, SnapshotList, RegistryAuth, ResizeOptions, Sandbox as SandboxData, SandboxSnapshot, SandboxStatus, Session, SessionAttachHandle, SessionAttachOptions, SessionStatus, Template, TemplatePushState, TemplateStatus, WasmModule, WasmModuleStatus, PushWasmModuleOptions, PushWasmModuleResult, UpdateLifecycleOptions, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { MicroVM } from "./MicroVM.js";
2
2
  export { Sandbox } from "./Sandbox.js";
3
3
  export { Image } from "./Image.js";
4
+ export { MicroVMApiError } from "./errors.js";
@@ -1,6 +1,11 @@
1
1
  import { Image } from "../Image.js";
2
- import type { AddCustomDomainOptions, BinaryLike, BuildImageOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, CustomDomain, CloneGeneration, CustomDomainDNSRecords, ExecRequest, ExecResult, ExecStreamHandle, ExecStreamOptions, ExposedPort, ExposePortOptions, ExposeResult, Failover, HealthStatus, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, NetworkUsage, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, RegisterSnapshotOptions, SetNetworkLimitsOptions, ResizeOptions, Sandbox, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule } from "../types.js";
2
+ import type { AddCustomDomainOptions, APITokenList, BinaryLike, BuildImageOptions, BuildImageResult, CreateAPITokenOptions, CreatedAPIToken, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, CustomDomain, CloneGeneration, CustomDomainDNSRecords, ExecRequest, ExecResult, ExecStreamHandle, ExecStreamOptions, ExposedPort, ExposePortOptions, ExposeResult, Failover, HealthStatus, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, NetworkUsage, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, ListSnapshotsOptions, RegisterSnapshotOptions, SnapshotList, SetNetworkLimitsOptions, ResizeOptions, Sandbox, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule, WhoAmI } from "../types.js";
3
3
  type FetchLike = typeof fetch;
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 declare const TEAM_HEADER = "X-AerolVM-Team";
4
9
  /**
5
10
  * Wire version of the v1-style sandbox endpoints (`/v1/sandboxes`, etc.) this
6
11
  * client speaks. The SDK and the server version independently — bumping the
@@ -36,10 +41,24 @@ export interface RetryConfig {
36
41
  * waits when `maxRetries` is high. Defaults to `5000`.
37
42
  */
38
43
  maxDelayMs?: number;
44
+ /**
45
+ * Cap in milliseconds on the wait before re-sending a 429 or 503. The SDK
46
+ * waits as long as the server's `Retry-After` header asks (seconds or an
47
+ * HTTP date), never less than its own backoff, plus up to a fifth more so
48
+ * clients told the same value spread out; a longer ask is cut to this. A
49
+ * missing or malformed header uses the backoff above. Defaults to
50
+ * `30000`; `0` ignores `Retry-After`.
51
+ */
52
+ maxRetryAfterMs?: number;
39
53
  }
40
54
  export interface APIClientConfig {
41
55
  baseURL: string;
42
56
  patToken?: string;
57
+ /**
58
+ * Sent as the {@link TEAM_HEADER} header on every HTTP request. Empty or
59
+ * absent sends no header.
60
+ */
61
+ team?: string;
43
62
  fetch?: FetchLike;
44
63
  /**
45
64
  * Wire version to call. Defaults to "v1". Pin this if you need a specific
@@ -49,11 +68,44 @@ export interface APIClientConfig {
49
68
  apiVersion?: APIVersion;
50
69
  /**
51
70
  * Retry policy for transient transport errors (socket closed, connection
52
- * reset) and retryable HTTP status codes (421, 429, 502, 503, 504). Pass
53
- * `{ maxRetries: 0 }` to disable retry entirely.
71
+ * reset) and retryable HTTP status codes (421, 429, 502, 503, 504). `exec`
72
+ * is narrower: it retries only failures that prove the command never
73
+ * reached the server (connection refused, connect timeout, DNS) and HTTP
74
+ * 429/503. Pass `{ maxRetries: 0 }` to disable retry entirely.
54
75
  */
55
76
  retry?: RetryConfig;
77
+ /**
78
+ * Cancels the client's HTTP requests: aborting it ends the request in
79
+ * flight and any retry wait, so a call never sits out a long
80
+ * `Retry-After` once aborted. WebSocket streams are not affected.
81
+ */
82
+ signal?: AbortSignal;
56
83
  }
84
+ /**
85
+ * The header a sandbox create carries so the server can recognise a retry of
86
+ * it. Keep in sync with clustercreate.HeaderIdempotencyKey on the server.
87
+ */
88
+ export declare const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
89
+ /** A fresh create key: 128 random bits as 32 lowercase hex characters. */
90
+ export declare function newIdempotencyKey(): string;
91
+ /**
92
+ * Reads a `Retry-After` value in either form RFC 9110 allows: delta-seconds
93
+ * (`"120"`) or an HTTP date. Returns the wait in milliseconds (0 for a date
94
+ * already past), or `undefined` for an absent or malformed value.
95
+ */
96
+ export declare function parseRetryAfter(value: string | null | undefined, nowMs: number): number | undefined;
97
+ /**
98
+ * How long {@link APIClient.request} waits before re-sending. `attempt`
99
+ * counts from 0, `retryAfter` is a 429/503's `Retry-After` header (null
100
+ * otherwise) and `random` is uniform in [0, 1).
101
+ *
102
+ * Without a usable header it is the exponential backoff ±25%. With one, it
103
+ * never retries sooner than the server asked (nor sooner than the backoff)
104
+ * and adds up to a fifth so clients told the same value spread out.
105
+ * `maxRetryAfterMs` is a hard ceiling: when the server asks for at least
106
+ * that long the client is early anyway, so the spread goes below it instead.
107
+ */
108
+ export declare function retryDelayMs(config: Required<RetryConfig>, attempt: number, retryAfter: string | null, nowMs: number, random: number): number;
57
109
  /**
58
110
  * Trim trailing slashes from a URL without a backtracking regex. The natural
59
111
  * `value.replace(/\/+$/, "")` is an anchored `+` quantifier — a polynomial
@@ -64,9 +116,11 @@ export declare class APIClient {
64
116
  readonly baseURL: string;
65
117
  readonly apiVersion: APIVersion;
66
118
  private readonly patToken;
119
+ private readonly team;
67
120
  private readonly fetchFn;
68
121
  private readonly versionPrefix;
69
122
  private readonly retryConfig;
123
+ private readonly signal;
70
124
  constructor(config: APIClientConfig);
71
125
  /**
72
126
  * Build a versioned API path. Pass the suffix beginning with "/" (e.g.
@@ -106,6 +160,9 @@ export declare class APIClient {
106
160
  createSnapshot(id: string, name: string): Promise<SandboxSnapshot>;
107
161
  registerSnapshot(options: RegisterSnapshotOptions): Promise<SandboxSnapshot>;
108
162
  registerSnapshotFromImage(name: string, image: Image, options?: Omit<RegisterSnapshotOptions, "name" | "image" | "dockerfileContent">): Promise<SandboxSnapshot>;
163
+ getSnapshot(name: string): Promise<SandboxSnapshot>;
164
+ listSnapshots(options?: ListSnapshotsOptions): Promise<SnapshotList>;
165
+ deleteSnapshot(name: string): Promise<void>;
109
166
  destroy(id: string): Promise<void>;
110
167
  resize(id: string, options: ResizeOptions): Promise<SandboxResource>;
111
168
  updateLifecycle(id: string, lifecycle: Lifecycle): Promise<SandboxResource>;
@@ -181,19 +238,31 @@ export declare class APIClient {
181
238
  deleteWasmModule(id: string): Promise<void>;
182
239
  pushWasmModule(options: PushWasmModuleOptions): Promise<PushWasmModuleResult>;
183
240
  rebuildTemplate(id: string): Promise<Template>;
241
+ whoami(): Promise<WhoAmI>;
242
+ createAPIToken(options: CreateAPITokenOptions): Promise<CreatedAPIToken>;
243
+ listAPITokens(): Promise<APITokenList>;
244
+ revokeAPIToken(id: string): Promise<void>;
184
245
  private wrap;
185
246
  private doJSON;
186
247
  private doBytes;
187
248
  /**
188
249
  * Low-level HTTP request with automatic retry for transient failures.
189
250
  *
190
- * Transport-level errors (socket closed, connection reset, DNS blip) are
191
- * always retried regardless of HTTP method — the request never reached the
192
- * server so there is no idempotency concern.
251
+ * Transport-level errors (socket closed, connection reset, timeouts, DNS
252
+ * blip) and HTTP 421/429/502/503/504 are retried regardless of HTTP method.
253
+ * Several of those failures can happen after the server received the
254
+ * request, so a retry may deliver it twice; that is safe only because every
255
+ * mutating endpoint in the daemon is designed for idempotent retry (e.g.
256
+ * INSERT OR IGNORE + disambiguation).
257
+ *
258
+ * A request passed `{ idempotent: false }` (exec) retries only transport
259
+ * failures that prove the request never left the client and HTTP 429/503;
260
+ * everything else is surfaced to the caller rather than risk running the
261
+ * command twice.
193
262
  *
194
- * HTTP-level retryable codes (421, 429, 502, 503, 504) are retried for ALL
195
- * methods because every mutating endpoint in the daemon is already
196
- * designed for idempotent retry (e.g. INSERT OR IGNORE + disambiguation).
263
+ * A 429 or 503 waits as long as its `Retry-After` asks (see
264
+ * {@link retryDelayMs}). The client's `signal` ends the request and any
265
+ * wait.
197
266
  */
198
267
  private request;
199
268
  }
@@ -1,10 +1,41 @@
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
+ import { MicroVMApiError } from "../errors.js";
3
4
  import { Image } from "../Image.js";
5
+ /**
6
+ * The request header that selects which of a credential's teams a request
7
+ * acts for. Keep in sync with pkg/api.HeaderTeam on the server.
8
+ */
9
+ export const TEAM_HEADER = "X-AerolVM-Team";
4
10
  const DEFAULT_API_VERSION = "v1";
5
11
  const PATH_PREFIXES = {
6
12
  v1: V1_PATH_PREFIX,
7
13
  };
14
+ function fromApiWhoAmI(w) {
15
+ const out = {
16
+ principal: { kind: w.principal.kind, id: w.principal.id },
17
+ role: w.role,
18
+ };
19
+ if (w.principal.name)
20
+ out.principal.name = w.principal.name;
21
+ if (w.team)
22
+ out.team = w.team;
23
+ if (w.expires_at)
24
+ out.expiresAt = w.expires_at;
25
+ return out;
26
+ }
27
+ function fromApiAPIToken(t) {
28
+ return {
29
+ id: t.id,
30
+ name: t.name,
31
+ role: t.role,
32
+ team: t.team,
33
+ createdBy: t.created_by,
34
+ createdAt: t.created_at,
35
+ expiresAt: t.expires_at,
36
+ expired: t.expired === true,
37
+ };
38
+ }
8
39
  function fromApiEgressProfile(p) {
9
40
  const out = {
10
41
  name: p.name,
@@ -29,17 +60,89 @@ function fromApiEgressRule({ inject, ...rule }) {
29
60
  // The server closes the connection, so the retry reconnects and the ingress
30
61
  // re-routes it (plans/ingress-proxy-routing.md, review 2A).
31
62
  const RETRYABLE_STATUS_CODES = new Set([421, 429, 502, 503, 504]);
63
+ // The subset a non-idempotent request (exec) may retry: 429 and 503 are the
64
+ // server turning the request away before doing any work. 421, 502 and 504 come
65
+ // from forwarding/proxy hops, and the status alone can't prove that no hop
66
+ // already handed the command to the sandbox, so exec surfaces them. They are
67
+ // also the statuses whose Retry-After paces clients: 429 when this caller is
68
+ // over its share, 503 when the node sheds load. A Retry-After on a 421, 502 or
69
+ // 504 is a hop's, not the server's, so those use the normal backoff.
70
+ const REFUSED_STATUS_CODES = new Set([429, 503]);
71
+ /**
72
+ * The header a sandbox create carries so the server can recognise a retry of
73
+ * it. Keep in sync with clustercreate.HeaderIdempotencyKey on the server.
74
+ */
75
+ export const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
76
+ /** A fresh create key: 128 random bits as 32 lowercase hex characters. */
77
+ export function newIdempotencyKey() {
78
+ // getRandomValues, unlike randomUUID, also works on a page served over
79
+ // plain http.
80
+ const bytes = new Uint8Array(16);
81
+ globalThis.crypto.getRandomValues(bytes);
82
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
83
+ }
32
84
  /** Default retry settings when the caller doesn't supply a RetryConfig. */
33
85
  const DEFAULT_RETRY = {
34
86
  maxRetries: 3,
35
87
  baseDelayMs: 200,
36
88
  maxDelayMs: 5_000,
89
+ // An overloaded node asks for about a second, so the cap only bites on a
90
+ // long ask (maintenance, a misconfigured proxy), which should not hold a
91
+ // call for minutes per retry.
92
+ maxRetryAfterMs: 30_000,
37
93
  };
94
+ // IMF-fixdate, the HTTP-date form servers send ("Sun, 06 Nov 1994 08:49:37
95
+ // GMT"). Date.parse alone would also take "1" or a date with no zone, read in
96
+ // local time.
97
+ const IMF_FIXDATE = /^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
98
+ /**
99
+ * Reads a `Retry-After` value in either form RFC 9110 allows: delta-seconds
100
+ * (`"120"`) or an HTTP date. Returns the wait in milliseconds (0 for a date
101
+ * already past), or `undefined` for an absent or malformed value.
102
+ */
103
+ export function parseRetryAfter(value, nowMs) {
104
+ const trimmed = (value ?? "").trim();
105
+ if (/^[0-9]+$/.test(trimmed)) {
106
+ return Number(trimmed) * 1000;
107
+ }
108
+ if (!IMF_FIXDATE.test(trimmed)) {
109
+ return undefined;
110
+ }
111
+ const when = Date.parse(trimmed);
112
+ return Number.isNaN(when) ? undefined : Math.max(when - nowMs, 0);
113
+ }
114
+ /**
115
+ * How long {@link APIClient.request} waits before re-sending. `attempt`
116
+ * counts from 0, `retryAfter` is a 429/503's `Retry-After` header (null
117
+ * otherwise) and `random` is uniform in [0, 1).
118
+ *
119
+ * Without a usable header it is the exponential backoff ±25%. With one, it
120
+ * never retries sooner than the server asked (nor sooner than the backoff)
121
+ * and adds up to a fifth so clients told the same value spread out.
122
+ * `maxRetryAfterMs` is a hard ceiling: when the server asks for at least
123
+ * that long the client is early anyway, so the spread goes below it instead.
124
+ */
125
+ export function retryDelayMs(config, attempt, retryAfter, nowMs, random) {
126
+ const backoff = Math.min(config.baseDelayMs * 2 ** attempt, config.maxDelayMs);
127
+ const limit = config.maxRetryAfterMs;
128
+ const hint = limit > 0 ? parseRetryAfter(retryAfter, nowMs) : undefined;
129
+ if (hint === undefined) {
130
+ return backoff * (0.75 + 0.5 * random);
131
+ }
132
+ const target = Math.max(hint, backoff);
133
+ if (target >= limit) {
134
+ return limit - limit * 0.2 * random;
135
+ }
136
+ const upper = Math.min(target * 1.2, limit);
137
+ return target + (upper - target) * random;
138
+ }
38
139
  /**
39
140
  * Returns `true` when the thrown `error` looks like a transport-level failure
40
141
  * that vanishes on retry (connection reset, socket closed by peer, DNS
41
142
  * blip). `undici` and Node core use several `cause.code` values; we check
42
- * the full cause chain.
143
+ * the full cause chain. A reset, EPIPE, read/headers/body timeout or "other
144
+ * side closed" can fire after the server already received the request, so
145
+ * this classification is only safe for idempotent requests.
43
146
  */
44
147
  function isTransientTransportError(error) {
45
148
  const codes = new Set([
@@ -69,6 +172,45 @@ function isTransientTransportError(error) {
69
172
  }
70
173
  return false;
71
174
  }
175
+ /**
176
+ * Returns `true` only for transport failures that prove the request never
177
+ * left the client: the connection was refused, did not open before the
178
+ * connect timeout, or the host name did not resolve (transiently; ENOTFOUND
179
+ * is not retried for any request). Any other code in the cause chain means
180
+ * bytes may have reached the server, so the answer is `false`.
181
+ */
182
+ function isUnsentTransportError(error) {
183
+ let current = error;
184
+ for (let depth = 0; depth < 5 && current instanceof Error; depth++) {
185
+ const err = current;
186
+ switch (err.code) {
187
+ case undefined:
188
+ break;
189
+ case "ECONNREFUSED":
190
+ case "EAI_AGAIN":
191
+ case "UND_ERR_CONNECT_TIMEOUT":
192
+ return true;
193
+ case "ETIMEDOUT":
194
+ // Node uses ETIMEDOUT both for a connect that never completed and for
195
+ // an open socket that stopped answering; only the first is unsent.
196
+ return failedWhileConnecting(err);
197
+ default:
198
+ return false;
199
+ }
200
+ current = err.cause;
201
+ }
202
+ return false;
203
+ }
204
+ function failedWhileConnecting(err) {
205
+ if (err.syscall === "connect")
206
+ return true;
207
+ // With autoSelectFamily (Node's default), a host with several addresses
208
+ // fails with an AggregateError holding one error per connect attempt and no
209
+ // syscall of its own.
210
+ return (err instanceof AggregateError &&
211
+ err.errors.length > 0 &&
212
+ err.errors.every((inner) => inner?.syscall === "connect"));
213
+ }
72
214
  /**
73
215
  * Trim trailing slashes from a URL without a backtracking regex. The natural
74
216
  * `value.replace(/\/+$/, "")` is an anchored `+` quantifier — a polynomial
@@ -81,21 +223,37 @@ export function stripTrailingSlashes(value) {
81
223
  }
82
224
  return value.slice(0, end);
83
225
  }
84
- /** Sleep with ±25% jitter so concurrent callers don't thundering-herd. */
85
- function jitteredDelay(baseMs) {
86
- const jitter = 1 + (Math.random() - 0.5) * 0.5; // 0.75 – 1.25
87
- return new Promise((resolve) => setTimeout(resolve, Math.round(baseMs * jitter)));
226
+ /** Waits `ms`, or rejects with the signal's reason as soon as it aborts. */
227
+ function sleep(ms, signal) {
228
+ return new Promise((resolve, reject) => {
229
+ if (signal?.aborted) {
230
+ reject(signal.reason);
231
+ return;
232
+ }
233
+ const onAbort = () => {
234
+ clearTimeout(timer);
235
+ reject(signal?.reason);
236
+ };
237
+ const timer = setTimeout(() => {
238
+ signal?.removeEventListener("abort", onAbort);
239
+ resolve();
240
+ }, ms);
241
+ signal?.addEventListener("abort", onAbort, { once: true });
242
+ });
88
243
  }
89
244
  export class APIClient {
90
245
  baseURL;
91
246
  apiVersion;
92
247
  patToken;
248
+ team;
93
249
  fetchFn;
94
250
  versionPrefix;
95
251
  retryConfig;
252
+ signal;
96
253
  constructor(config) {
97
254
  this.baseURL = stripTrailingSlashes(config.baseURL);
98
255
  this.patToken = config.patToken ?? "";
256
+ this.team = (config.team ?? "").trim();
99
257
  this.fetchFn = config.fetch ?? fetch;
100
258
  this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;
101
259
  this.versionPrefix = PATH_PREFIXES[this.apiVersion];
@@ -103,7 +261,9 @@ export class APIClient {
103
261
  maxRetries: config.retry?.maxRetries ?? DEFAULT_RETRY.maxRetries,
104
262
  baseDelayMs: config.retry?.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
105
263
  maxDelayMs: config.retry?.maxDelayMs ?? DEFAULT_RETRY.maxDelayMs,
264
+ maxRetryAfterMs: config.retry?.maxRetryAfterMs ?? DEFAULT_RETRY.maxRetryAfterMs,
106
265
  };
266
+ this.signal = config.signal;
107
267
  }
108
268
  /**
109
269
  * Build a versioned API path. Pass the suffix beginning with "/" (e.g.
@@ -116,7 +276,13 @@ export class APIClient {
116
276
  }
117
277
  async create(options) {
118
278
  const resolved = await this.resolveImage(options);
119
- const response = await this.doJSON("POST", this.versioned("/sandboxes"), toApiCreateOptions(resolved));
279
+ // One key per call, sent unchanged on each of its automatic retries: a
280
+ // create retried after a timeout gets back the sandbox the first attempt
281
+ // made instead of making a second one.
282
+ const key = options.idempotencyKey?.trim() || newIdempotencyKey();
283
+ const response = await this.doJSON("POST", this.versioned("/sandboxes"), toApiCreateOptions(resolved), {
284
+ headers: { [IDEMPOTENCY_KEY_HEADER]: key },
285
+ });
120
286
  return new SandboxResource(this, fromApiCreateSandboxResponse(response));
121
287
  }
122
288
  /**
@@ -272,6 +438,25 @@ export class APIClient {
272
438
  dockerfileContent: image.dockerfile,
273
439
  });
274
440
  }
441
+ async getSnapshot(name) {
442
+ return fromApiSandboxSnapshot(await this.doJSON("GET", this.versioned(`/snapshots/${encodeURIComponent(name)}`)));
443
+ }
444
+ async listSnapshots(options = {}) {
445
+ const params = new URLSearchParams();
446
+ if (options.limit)
447
+ params.set("limit", String(options.limit));
448
+ if (options.startingAfter)
449
+ params.set("starting_after", options.startingAfter);
450
+ const query = params.toString();
451
+ const response = await this.doJSON("GET", this.versioned(`/snapshots${query ? `?${query}` : ""}`));
452
+ return {
453
+ snapshots: (response.snapshots ?? []).map(fromApiSandboxSnapshot),
454
+ hasMore: response.has_more ?? false,
455
+ };
456
+ }
457
+ async deleteSnapshot(name) {
458
+ await this.doJSON("DELETE", this.versioned(`/snapshots/${encodeURIComponent(name)}`));
459
+ }
275
460
  async destroy(id) {
276
461
  await this.doJSON("DELETE", `${this.versionPrefix}/sandboxes/${id}`);
277
462
  }
@@ -284,7 +469,8 @@ export class APIClient {
284
469
  return this.wrap(response);
285
470
  }
286
471
  async exec(id, request) {
287
- const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/process/execute`, toApiExecRequest(request));
472
+ // Not idempotent: a re-sent exec runs the caller's command a second time.
473
+ const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/process/execute`, toApiExecRequest(request), { idempotent: false });
288
474
  return fromApiExecResult(response);
289
475
  }
290
476
  execStream(id, options) {
@@ -571,18 +757,39 @@ export class APIClient {
571
757
  const response = await this.doJSON("POST", `${this.versionPrefix}/templates/${id}/rebuild`);
572
758
  return fromApiTemplate(response);
573
759
  }
760
+ async whoami() {
761
+ return fromApiWhoAmI(await this.doJSON("GET", this.versioned("/whoami")));
762
+ }
763
+ async createAPIToken(options) {
764
+ const response = await this.doJSON("POST", this.versioned("/tokens"), {
765
+ name: options.name,
766
+ role: options.role,
767
+ team: options.team,
768
+ expires_in: options.expiresIn,
769
+ });
770
+ return { ...fromApiAPIToken(response), token: response.token };
771
+ }
772
+ async listAPITokens() {
773
+ const response = await this.doJSON("GET", this.versioned("/tokens"));
774
+ return { tokens: (response.tokens ?? []).map(fromApiAPIToken) };
775
+ }
776
+ async revokeAPIToken(id) {
777
+ const trimmed = id.trim();
778
+ if (trimmed === "") {
779
+ throw new Error("token id is required");
780
+ }
781
+ await this.doJSON("DELETE", this.versioned(`/tokens/${encodeURIComponent(trimmed)}`));
782
+ }
574
783
  wrap(sandbox) {
575
784
  return new SandboxResource(this, fromApiSandbox(sandbox));
576
785
  }
577
- async doJSON(method, path, body) {
578
- const init = {};
786
+ async doJSON(method, path, body, options) {
787
+ const init = { headers: { ...options?.headers } };
579
788
  if (body !== undefined) {
580
789
  init.body = JSON.stringify(body);
581
- init.headers = {
582
- "Content-Type": "application/json",
583
- };
790
+ init.headers = { ...init.headers, "Content-Type": "application/json" };
584
791
  }
585
- const response = await this.request(method, path, init);
792
+ const response = await this.request(method, path, init, options);
586
793
  if (!response.ok) {
587
794
  throw await decodeError(response);
588
795
  }
@@ -601,46 +808,59 @@ export class APIClient {
601
808
  /**
602
809
  * Low-level HTTP request with automatic retry for transient failures.
603
810
  *
604
- * Transport-level errors (socket closed, connection reset, DNS blip) are
605
- * always retried regardless of HTTP method — the request never reached the
606
- * server so there is no idempotency concern.
811
+ * Transport-level errors (socket closed, connection reset, timeouts, DNS
812
+ * blip) and HTTP 421/429/502/503/504 are retried regardless of HTTP method.
813
+ * Several of those failures can happen after the server received the
814
+ * request, so a retry may deliver it twice; that is safe only because every
815
+ * mutating endpoint in the daemon is designed for idempotent retry (e.g.
816
+ * INSERT OR IGNORE + disambiguation).
817
+ *
818
+ * A request passed `{ idempotent: false }` (exec) retries only transport
819
+ * failures that prove the request never left the client and HTTP 429/503;
820
+ * everything else is surfaced to the caller rather than risk running the
821
+ * command twice.
607
822
  *
608
- * HTTP-level retryable codes (421, 429, 502, 503, 504) are retried for ALL
609
- * methods because every mutating endpoint in the daemon is already
610
- * designed for idempotent retry (e.g. INSERT OR IGNORE + disambiguation).
823
+ * A 429 or 503 waits as long as its `Retry-After` asks (see
824
+ * {@link retryDelayMs}). The client's `signal` ends the request and any
825
+ * wait.
611
826
  */
612
- async request(method, path, init = {}) {
827
+ async request(method, path, init = {}, options = {}) {
613
828
  const headers = new Headers(init.headers);
614
829
  if (this.patToken !== "") {
615
830
  headers.set("Authorization", `Bearer ${this.patToken}`);
616
831
  }
832
+ if (this.team !== "") {
833
+ headers.set(TEAM_HEADER, this.team);
834
+ }
617
835
  const url = `${this.baseURL}${path}`;
618
- const requestInit = { ...init, method, headers };
619
- const { maxRetries, baseDelayMs, maxDelayMs } = this.retryConfig;
620
- let lastError;
621
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
836
+ const signal = init.signal ?? this.signal;
837
+ const requestInit = { ...init, method, headers, ...(signal ? { signal } : {}) };
838
+ const { maxRetries } = this.retryConfig;
839
+ const idempotent = options.idempotent ?? true;
840
+ const retryableStatusCodes = idempotent ? RETRYABLE_STATUS_CODES : REFUSED_STATUS_CODES;
841
+ const isRetryableError = idempotent ? isTransientTransportError : isUnsentTransportError;
842
+ for (let attempt = 0;; attempt++) {
843
+ let retryAfter = null;
622
844
  try {
623
845
  const response = await this.fetchFn(url, requestInit);
624
- // Retry on transient HTTP status codes.
625
- if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxRetries) {
626
- const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
627
- await jitteredDelay(delay);
628
- continue;
846
+ if (!retryableStatusCodes.has(response.status) || attempt >= maxRetries) {
847
+ return response;
848
+ }
849
+ if (REFUSED_STATUS_CODES.has(response.status)) {
850
+ retryAfter = response.headers.get("retry-after");
629
851
  }
630
- return response;
852
+ // Free the connection instead of holding it through the wait.
853
+ response.body?.cancel().catch(() => undefined);
631
854
  }
632
855
  catch (error) {
633
- lastError = error;
634
- // Only retry transport-level failures; anything else propagates.
635
- if (!isTransientTransportError(error) || attempt >= maxRetries) {
856
+ // Only retry transport-level failures; anything else propagates. An
857
+ // aborted signal surfaces whatever fetch threw for it.
858
+ if (signal?.aborted || !isRetryableError(error) || attempt >= maxRetries) {
636
859
  throw error;
637
860
  }
638
- const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
639
- await jitteredDelay(delay);
640
861
  }
862
+ await sleep(retryDelayMs(this.retryConfig, attempt, retryAfter, Date.now(), Math.random()), signal);
641
863
  }
642
- // Should be unreachable — the loop always throws or returns.
643
- throw lastError;
644
864
  }
645
865
  }
646
866
  export class SandboxResource {
@@ -826,6 +1046,15 @@ function toApiCreateOptions(options) {
826
1046
  module_ref: options.moduleRef,
827
1047
  tenant_id: options.tenantId,
828
1048
  custom_domains: options.customDomains,
1049
+ gpus: options.gpus ? toApiGPUOptions(options.gpus) : undefined,
1050
+ template_id: options.templateId,
1051
+ };
1052
+ }
1053
+ function toApiGPUOptions(gpus) {
1054
+ return {
1055
+ vendor: gpus.vendor,
1056
+ count: gpus.count,
1057
+ device_ids: gpus.deviceIDs,
829
1058
  };
830
1059
  }
831
1060
  function toApiRegisterSnapshotOptions(options) {
@@ -1221,16 +1450,19 @@ function toBlob(data) {
1221
1450
  return new Blob([Uint8Array.from(data)]);
1222
1451
  }
1223
1452
  async function decodeError(response) {
1453
+ const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"), Date.now());
1224
1454
  try {
1225
1455
  const payload = (await response.json());
1226
1456
  if (typeof payload.error === "string" && payload.error !== "") {
1227
- return new Error(payload.error);
1457
+ const code = typeof payload.code === "string" && payload.code !== "" ? payload.code : undefined;
1458
+ return new MicroVMApiError(response.status, payload.error, { code, retryAfterMs });
1228
1459
  }
1229
1460
  }
1230
1461
  catch {
1231
- // Fall through to status-based error.
1462
+ // Not JSON (a proxy's error page) or no body: fall through to the
1463
+ // status-based message, with no code.
1232
1464
  }
1233
- return new Error(`request failed with status ${response.status}`);
1465
+ return new MicroVMApiError(response.status, `request failed with status ${response.status}`, { retryAfterMs });
1234
1466
  }
1235
1467
  const STREAM_PREFIX_STDOUT = 0x01;
1236
1468
  const STREAM_PREFIX_STDERR = 0x02;
package/dist/types.d.ts CHANGED
@@ -135,6 +135,19 @@ export interface GPUOptions {
135
135
  }
136
136
  import type { Image } from "./Image.js";
137
137
  export interface CreateOptions {
138
+ /**
139
+ * Sent as the create's `Idempotency-Key` header, never in the body. A create
140
+ * sent again with the same key and the same options returns the sandbox the
141
+ * first one made instead of making a second; the same key with different
142
+ * options fails with a 422 `MicroVMApiError` whose `code` is
143
+ * `"idempotency_key_reused"`. Keys are scoped to the caller's account.
144
+ *
145
+ * Leave it unset and the SDK makes a random key for each `create` call and
146
+ * reuses it on that call's automatic retries, so a retry after a timeout
147
+ * cannot make a second sandbox. Set your own to make your own retries of
148
+ * the whole call safe too, such as after your process restarts.
149
+ */
150
+ idempotencyKey?: string;
138
151
  /**
139
152
  * Optional name, unique among the caller's sandboxes (another account may
140
153
  * use the same name). A repeat create with a name the caller already holds
@@ -194,9 +207,10 @@ export interface CreateOptions {
194
207
  */
195
208
  networkEgressRules?: EgressRule[];
196
209
  /**
197
- * Whether the sandbox may be exposed to the public internet. Omitted defaults
198
- * to private (no public URL, `exposePort` fails). Set `true` to opt in to
199
- * public exposure; `false` permanently refuses it for this sandbox.
210
+ * Whether the sandbox is public from boot. Omitted or `false` creates it
211
+ * private: no public URL and no ingress route. `true` makes it public from
212
+ * boot. Either way the first `exposePort` call makes the sandbox public, so
213
+ * `false` does not stop a later `exposePort`.
200
214
  */
201
215
  allowPublicTraffic?: boolean;
202
216
  /**
@@ -269,6 +283,13 @@ export interface CreateOptions {
269
283
  * `sandbox.customDomains.add(host)` for each entry after create.
270
284
  */
271
285
  customDomains?: string[];
286
+ /**
287
+ * Firecracker template id (from `createTemplate`); the template must be
288
+ * ready. Requires `runtime: "firecracker"` and `image`: pass the image the
289
+ * template was built from. The sandbox boots from the template's prepared
290
+ * rootfs instead of building one from `image` on every create.
291
+ */
292
+ templateId?: string;
272
293
  }
273
294
  export interface ResizeOptions {
274
295
  /** Number of CPU cores. Fractional values supported (e.g. 0.5). */
@@ -445,6 +466,19 @@ export interface SandboxSnapshot {
445
466
  memoryMB?: number;
446
467
  diskGB?: number;
447
468
  }
469
+ /** Options for {@link MicroVM.listSnapshots}: one name-ordered page. */
470
+ export interface ListSnapshotsOptions {
471
+ /** Maximum snapshots to return in this page. */
472
+ limit?: number;
473
+ /** Exclusive name cursor — pass the last name of the previous page. */
474
+ startingAfter?: string;
475
+ }
476
+ /** One page of snapshots from {@link MicroVM.listSnapshots}. */
477
+ export interface SnapshotList {
478
+ snapshots: SandboxSnapshot[];
479
+ /** True when more snapshots remain after this page. */
480
+ hasMore: boolean;
481
+ }
448
482
  /**
449
483
  * Discriminated result from `exposePort`. Branch on `protocol` to access the
450
484
  * fields that are meaningful for the chosen wire protocol — only the raw-TCP
@@ -482,8 +516,9 @@ export interface Sandbox {
482
516
  networkBlockAll: boolean;
483
517
  /**
484
518
  * Hostname-egress state on `get` for a sandbox whose allow list names
485
- * hosts (container runtimes): "active", "held" or "unavailable". Absent
486
- * otherwise, and on list results.
519
+ * hosts (container runtimes): "active", "held" or "unavailable";
520
+ * "unenforced" when the server's firewall is off and nothing filters the
521
+ * sandbox. Absent otherwise, and on list results.
487
522
  */
488
523
  egressStatus?: string;
489
524
  /** Egress profiles this sandbox references. */
@@ -629,7 +664,10 @@ export interface NetworkPolicy {
629
664
  networkEgressRules: EgressRule[];
630
665
  /** Hostname entries in force: inline plus every profile's (at most 1024). */
631
666
  effectiveHostnameCount: number;
632
- /** "active", "held" or "unavailable" for hostname rules on a container. */
667
+ /**
668
+ * "active", "held" or "unavailable" for hostname rules on a container;
669
+ * "unenforced" when the server's firewall is off.
670
+ */
633
671
  egressStatus?: string;
634
672
  }
635
673
  /**
@@ -722,6 +760,59 @@ export interface EgressProfileList {
722
760
  /** Pass back as `cursor` for the next page; absent on the last one. */
723
761
  nextCursor?: string;
724
762
  }
763
+ /** Who the caller is, as `microvm.whoami()` reports it. */
764
+ export interface WhoAmIPrincipal {
765
+ /** `"operator"`, `"token"`, `"oidc"`, `"controlplane"`, ... */
766
+ kind: string;
767
+ /** Stable id: `"operator"`, `"tok_…"`, `"oidc:<issuer>:<sub>"`, ... */
768
+ id: string;
769
+ /** Display name; absent when the server has none. */
770
+ name?: string;
771
+ }
772
+ /** The body of `microvm.whoami()`: the caller's own resolved identity. */
773
+ export interface WhoAmI {
774
+ principal: WhoAmIPrincipal;
775
+ /** `"viewer"`, `"runner"`, `"auditor"` or `"admin"`. */
776
+ role: string;
777
+ /** `"*"` for every team, a team name, or absent for a managed account. */
778
+ team?: string;
779
+ /** When the credential stops working (RFC 3339); absent if it never does. */
780
+ expiresAt?: string;
781
+ }
782
+ /** One scoped API token as `microvm.tokens.list()` returns it. Never the secret. */
783
+ export interface APIToken {
784
+ /** Public id (`tok_…`): what `tokens.revoke` takes and audit records name. */
785
+ id: string;
786
+ name: string;
787
+ role: string;
788
+ /** A team name, or `"*"` for every team. */
789
+ team: string;
790
+ createdBy: string;
791
+ createdAt: string;
792
+ expiresAt: string;
793
+ /** Expired tokens no longer work; they stay listed for 30 days. */
794
+ expired: boolean;
795
+ }
796
+ /** `microvm.tokens.create()`'s result: the token plus its secret. */
797
+ export interface CreatedAPIToken extends APIToken {
798
+ /** The secret (`avm_…`), returned this once and never stored by the server. */
799
+ token: string;
800
+ }
801
+ /** The body of `microvm.tokens.create()`. */
802
+ export interface CreateAPITokenOptions {
803
+ /** 1 to 64 of `A-Z a-z 0-9 . _ -`, unique among the team's tokens. */
804
+ name: string;
805
+ /** `"viewer"`, `"runner"`, `"auditor"` or `"admin"`. */
806
+ role: string;
807
+ /** A team name, or `"*"` for every team. `auditor` and `admin` require `"*"`. */
808
+ team: string;
809
+ /** Required lifetime: `"90d"`, `"720h"` or any Go duration. */
810
+ expiresIn: string;
811
+ }
812
+ /** `microvm.tokens.list()`'s result. */
813
+ export interface APITokenList {
814
+ tokens: APIToken[];
815
+ }
725
816
  /** One destination a learn-mode sandbox reached. */
726
817
  export interface NetworkLearnedEntry {
727
818
  host: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aerol-ai/aerolvm-sdk",
3
- "version": "0.6.4",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  "test": "tsc -p tsconfig.test.json --outDir dist-test && node --test dist-test/*.test.js dist-test/internal/*.test.js"
27
27
  },
28
28
  "devDependencies": {
29
- "@types/node": "^26.6.3",
29
+ "@types/node": "^26.6.6",
30
30
  "typescript": "^7.0.2",
31
31
  "tsx": "^4.23.15"
32
32
  }