@aerol-ai/aerolvm-sdk 0.8.1 → 0.8.2

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,6 +1,9 @@
1
- import { basename } from "node:path";
1
+ import { lstat, readdir, readFile, readlink, stat } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { gzipSync } from "node:zlib";
4
+ import { tarArchive } from "./tar.js";
2
5
  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";
6
+ import { MicroVMApiError, MicroVMConflictError, MicroVMFileTooLargeError, MicroVMNotFoundError, MicroVMUnsupportedFeatureError, apiErrorFor, } from "../errors.js";
4
7
  import { Image } from "../Image.js";
5
8
  /**
6
9
  * The request header that selects which of a credential's teams a request
@@ -36,6 +39,18 @@ function fromApiAPIToken(t) {
36
39
  expired: t.expired === true,
37
40
  };
38
41
  }
42
+ function fromApiTeamQuota(q) {
43
+ const out = { team: q.team, generation: q.generation, createdAt: q.created_at, updatedAt: q.updated_at };
44
+ if (q.max_sandboxes)
45
+ out.maxSandboxes = q.max_sandboxes;
46
+ if (q.max_cpu)
47
+ out.maxCPU = q.max_cpu;
48
+ if (q.max_memory_mb)
49
+ out.maxMemoryMB = q.max_memory_mb;
50
+ if (q.usage)
51
+ out.usage = { sandboxes: q.usage.sandboxes, cpu: q.usage.cpu, memoryMB: q.usage.memory_mb };
52
+ return out;
53
+ }
39
54
  function fromApiEgressProfile(p) {
40
55
  const out = {
41
56
  name: p.name,
@@ -48,12 +63,74 @@ function fromApiEgressProfile(p) {
48
63
  out.description = p.description;
49
64
  return out;
50
65
  }
66
+ function fromApiWarmPool(p) {
67
+ const out = {
68
+ image: p.image,
69
+ depth: p.depth,
70
+ onImageChange: (p.on_image_change || "replace_now"),
71
+ generation: p.generation,
72
+ createdAt: p.created_at,
73
+ updatedAt: p.updated_at,
74
+ };
75
+ if (p.min_depth)
76
+ out.minDepth = p.min_depth;
77
+ if (p.max_depth)
78
+ out.maxDepth = p.max_depth;
79
+ if (p.status) {
80
+ const status = { ready: p.status.ready, target: p.status.target, outdated: p.status.outdated ?? 0, servers: [] };
81
+ for (const s of p.status.servers ?? []) {
82
+ const server = { nodeId: s.node_id, ready: s.ready, target: s.target, outdated: s.outdated ?? 0 };
83
+ if (s.stale)
84
+ server.stale = true;
85
+ status.servers.push(server);
86
+ }
87
+ out.status = status;
88
+ }
89
+ return out;
90
+ }
91
+ // An image is the rest of the warm-pool path. Each segment is escaped and the
92
+ // slashes are kept, so ghcr.io/acme/agent:1 reaches the server as written and
93
+ // no proxy has an encoded slash to rewrite.
94
+ function warmPoolPath(image) {
95
+ return `/warm-pools/${image.split("/").map(encodeURIComponent).join("/")}`;
96
+ }
51
97
  function toApiEgressRule({ inject, ...rule }) {
52
98
  return inject ? { ...rule, inject: { header: inject.header, secret_ref: inject.secretRef } } : rule;
53
99
  }
54
100
  function fromApiEgressRule({ inject, ...rule }) {
55
101
  return inject ? { ...rule, inject: { header: inject.header, secretRef: inject.secret_ref } } : rule;
56
102
  }
103
+ function fromApiIdentityRole(r) {
104
+ const out = { tag: r.tag, minRole: r.min_role, createdAt: r.created_at, updatedAt: r.updated_at };
105
+ if (r.team)
106
+ out.team = r.team;
107
+ return out;
108
+ }
109
+ function fromApiSandboxIdentity(i) {
110
+ if (!i)
111
+ return undefined;
112
+ const out = { roleTag: i.role_tag, audience: i.audience, subject: i.subject, tokenFile: i.token_file };
113
+ if (i.aws_role_arn)
114
+ out.awsRoleArn = i.aws_role_arn;
115
+ if (i.azure_client_id)
116
+ out.azureClientId = i.azure_client_id;
117
+ if (i.azure_tenant_id)
118
+ out.azureTenantId = i.azure_tenant_id;
119
+ if (i.token_expires_at)
120
+ out.tokenExpiresAt = i.token_expires_at;
121
+ if (i.last_error)
122
+ out.lastError = i.last_error;
123
+ return out;
124
+ }
125
+ function toApiIdentity(i) {
126
+ return {
127
+ role_tag: i.roleTag,
128
+ audience: i.audience,
129
+ aws_role_arn: i.awsRoleArn,
130
+ azure_client_id: i.azureClientId,
131
+ azure_tenant_id: i.azureTenantId,
132
+ };
133
+ }
57
134
  /** HTTP status codes the retry loop considers transient. */
58
135
  // 421 Misdirected Request: an owner answered for a sandbox it doesn't hold
59
136
  // (HTTP/2 connection coalescing, or a stale ingress route after failover).
@@ -68,6 +145,25 @@ const RETRYABLE_STATUS_CODES = new Set([421, 429, 502, 503, 504]);
68
145
  // over its share, 503 when the node sheds load. A Retry-After on a 421, 502 or
69
146
  // 504 is a hop's, not the server's, so those use the normal backoff.
70
147
  const REFUSED_STATUS_CODES = new Set([429, 503]);
148
+ /**
149
+ * The toolboxd feature (on /version) behind the file verbs' options: list
150
+ * depth and cap, the move and copy overwrite guard, mkdir without parents and
151
+ * the copy route. An older agent ignores those options (overwrite=false would
152
+ * overwrite), so the calls that send them check first.
153
+ */
154
+ const FEATURE_FILE_VERBS = "files.verbs";
155
+ /**
156
+ * The toolboxd feature behind upload's write options. An older agent ignores
157
+ * them: createOnly would replace an existing file.
158
+ */
159
+ const FEATURE_WRITE_OPTIONS = "files.write_options";
160
+ /**
161
+ * The toolboxd feature behind upload's extract=true (uploadDir). An older
162
+ * agent ignores extract and would store the archive itself as a file.
163
+ */
164
+ const FEATURE_EXTRACT = "files.extract";
165
+ /** The entry cap `listFiles` sends when the caller gives none. */
166
+ export const DEFAULT_LIST_LIMIT = 10_000;
71
167
  /**
72
168
  * The header a sandbox create carries so the server can recognise a retry of
73
169
  * it. Keep in sync with clustercreate.HeaderIdempotencyKey on the server.
@@ -223,6 +319,21 @@ export function stripTrailingSlashes(value) {
223
319
  }
224
320
  return value.slice(0, end);
225
321
  }
322
+ /** A signal that aborts when any of `signals` does, with that one's reason. */
323
+ function anySignal(signals) {
324
+ const native = AbortSignal.any;
325
+ if (typeof native === "function")
326
+ return native.call(AbortSignal, signals);
327
+ const controller = new AbortController();
328
+ for (const signal of signals) {
329
+ if (signal.aborted) {
330
+ controller.abort(signal.reason);
331
+ break;
332
+ }
333
+ signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
334
+ }
335
+ return controller.signal;
336
+ }
226
337
  /** Waits `ms`, or rejects with the signal's reason as soon as it aborts. */
227
338
  function sleep(ms, signal) {
228
339
  return new Promise((resolve, reject) => {
@@ -244,15 +355,21 @@ function sleep(ms, signal) {
244
355
  export class APIClient {
245
356
  baseURL;
246
357
  apiVersion;
247
- patToken;
248
358
  team;
249
359
  fetchFn;
250
360
  versionPrefix;
251
361
  retryConfig;
252
362
  signal;
363
+ /** The client a {@link withSignal} view was made from; unset on a real client. */
364
+ root;
365
+ /**
366
+ * Features each sandbox's agent listed on /version (requireToolboxFeatures).
367
+ * Refreshed on a miss, because a restart can bring a newer agent.
368
+ */
369
+ toolboxFeatures = new Map();
253
370
  constructor(config) {
254
371
  this.baseURL = stripTrailingSlashes(config.baseURL);
255
- this.patToken = config.patToken ?? "";
372
+ Object.defineProperty(this, "patToken", { value: config.patToken ?? "", enumerable: false });
256
373
  this.team = (config.team ?? "").trim();
257
374
  this.fetchFn = config.fetch ?? fetch;
258
375
  this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;
@@ -274,6 +391,22 @@ export class APIClient {
274
391
  versioned(suffix) {
275
392
  return `${this.versionPrefix}${suffix}`;
276
393
  }
394
+ /**
395
+ * This client with `signal` added to its own: either one aborting ends a
396
+ * request and any retry wait. A per-call signal (plan S6) rides on a view
397
+ * like this rather than a parameter on every internal method. The view
398
+ * shares everything else, the per-sandbox feature cache included, and the
399
+ * sandboxes it returns hold the real client, so the call's signal never
400
+ * outlives the call. Without a signal it is this client.
401
+ */
402
+ withSignal(signal) {
403
+ if (!signal)
404
+ return this;
405
+ const view = Object.create(this);
406
+ Object.defineProperty(view, "signal", { value: this.signal ? anySignal([this.signal, signal]) : signal });
407
+ Object.defineProperty(view, "root", { value: this.root ?? this });
408
+ return view;
409
+ }
277
410
  async create(options) {
278
411
  const resolved = await this.resolveImage(options);
279
412
  // One key per call, sent unchanged on each of its automatic retries: a
@@ -392,6 +525,40 @@ export class APIClient {
392
525
  }
393
526
  return this.wrap(items[0]);
394
527
  }
528
+ /**
529
+ * The caller's sandbox called `name`, created from `options` when there is
530
+ * none; `created` is true only when this call made it. Safe to retry and to
531
+ * run concurrently: names are unique per owner, so a caller that loses a
532
+ * race to create the name gets a 409 and reads the winner's sandbox.
533
+ */
534
+ async getOrCreate(name, options) {
535
+ const wanted = name.trim();
536
+ if (wanted === "") {
537
+ throw new Error("sandbox name is required");
538
+ }
539
+ const given = options.name?.trim() ?? "";
540
+ if (given !== "" && given !== wanted) {
541
+ throw new Error(`options name "${given}" differs from "${wanted}"`);
542
+ }
543
+ const existing = await this.getByName(wanted);
544
+ if (existing !== null) {
545
+ return { sandbox: existing, created: false };
546
+ }
547
+ try {
548
+ return { sandbox: await this.create({ ...options, name: wanted }), created: true };
549
+ }
550
+ catch (err) {
551
+ if (!(err instanceof MicroVMConflictError))
552
+ throw err;
553
+ // Another caller created the name after the lookup. A 409 whose name
554
+ // still cannot be found is a conflict on something else, so the
555
+ // create's own error stands.
556
+ const winner = await this.getByName(wanted);
557
+ if (winner === null)
558
+ throw err;
559
+ return { sandbox: winner, created: false };
560
+ }
561
+ }
395
562
  async get(id, options) {
396
563
  const q = buildIncludeEnvQuery(options?.includeEnv);
397
564
  const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}${q}`);
@@ -464,6 +631,32 @@ export class APIClient {
464
631
  async destroy(id) {
465
632
  await this.doJSON("DELETE", `${this.versionPrefix}/sandboxes/${id}`);
466
633
  }
634
+ /**
635
+ * Deletes every sandbox carrying all of `tags`, one at a time, after
636
+ * listing every page; returns the IDs now gone. A 404 counts as deleted,
637
+ * so the call is safe to repeat. Any other failure stops it and is thrown.
638
+ */
639
+ async destroyAll(tags) {
640
+ if (!tags || Object.keys(tags).length === 0) {
641
+ throw new Error("destroyAll needs at least one tag; with none it would delete every sandbox you own");
642
+ }
643
+ const ids = [];
644
+ for await (const page of this.listPages({ tags })) {
645
+ ids.push(...page.map((sandbox) => sandbox.id));
646
+ }
647
+ const gone = [];
648
+ for (const id of ids) {
649
+ try {
650
+ await this.destroy(id);
651
+ }
652
+ catch (err) {
653
+ if (!(err instanceof MicroVMNotFoundError))
654
+ throw err;
655
+ }
656
+ gone.push(id);
657
+ }
658
+ return gone;
659
+ }
467
660
  async resize(id, options) {
468
661
  const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/resize`, toApiResizeOptions(options));
469
662
  return this.wrap(response);
@@ -472,13 +665,105 @@ export class APIClient {
472
665
  const response = await this.doJSON("PUT", `${this.versionPrefix}/sandboxes/${id}/lifecycle`, toApiLifecycle(lifecycle));
473
666
  return this.wrap(response);
474
667
  }
668
+ /**
669
+ * Fails closed unless the sandbox's agent lists every feature. toolboxd
670
+ * ignores request options it does not know, so sending a newer option to an
671
+ * older agent would silently skip it.
672
+ */
673
+ async requireToolboxFeatures(id, ...features) {
674
+ if (features.length === 0) {
675
+ return;
676
+ }
677
+ let have = this.toolboxFeatures.get(id);
678
+ let version = "";
679
+ let runtime = "";
680
+ if (!have || !features.every((feature) => have.has(feature))) {
681
+ const info = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/toolbox/version`);
682
+ have = new Set(Array.isArray(info?.features) ? info.features.map(String) : []);
683
+ this.toolboxFeatures.set(id, have);
684
+ version = typeof info?.version === "string" ? info.version : "";
685
+ runtime = typeof info?.runtime === "string" ? info.runtime : "";
686
+ }
687
+ const missing = features.filter((feature) => !have.has(feature));
688
+ if (missing.length > 0) {
689
+ throw new MicroVMUnsupportedFeatureError(id, missing, version, runtime);
690
+ }
691
+ }
475
692
  async exec(id, request) {
693
+ // An older agent ignores the caps and returns all output, and refuses
694
+ // argv with a confusing "command is required"; check first.
695
+ const features = [];
696
+ if (request.maxStdoutBytes || request.maxStderrBytes) {
697
+ features.push("exec.max_output_bytes");
698
+ }
699
+ if (request.argv?.length) {
700
+ features.push("exec.argv");
701
+ }
702
+ await this.requireToolboxFeatures(id, ...features);
476
703
  // Not idempotent: a re-sent exec runs the caller's command a second time.
477
704
  const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/process/execute`, toApiExecRequest(request), { idempotent: false });
478
705
  return fromApiExecResult(response);
479
706
  }
480
707
  execStream(id, options) {
481
- return openExecStream(this.baseURL, this.versionPrefix, this.patToken, id, options);
708
+ if (options.command?.trim() && options.argv?.length) {
709
+ throw new Error("set command or argv, not both");
710
+ }
711
+ // argv waits for the feature check: the start message goes out only once
712
+ // the agent has listed exec.argv, so an older one never receives it.
713
+ const ready = options.argv?.length ? this.requireToolboxFeatures(id, "exec.argv") : undefined;
714
+ return openExecStream(this.baseURL, this.versionPrefix, this.patToken, id, options, ready);
715
+ }
716
+ /**
717
+ * Watch a folder in the sandbox for changes. Resolves once the watch is in
718
+ * place, so a change made after it resolves is reported. gVisor and
719
+ * Firecracker sandboxes only: the others answer 501. Not retried: a stream
720
+ * is opened once.
721
+ */
722
+ async watchFiles(id, path, onEvent, options = {}) {
723
+ const query = `path=${encodeURIComponent(path)}${options.recursive ? "&recursive=true" : ""}`;
724
+ const routePath = `${this.versioned(`/sandboxes/${encodeURIComponent(id)}/toolbox/files/watch`)}?${query}`;
725
+ return openFileWatch(this.baseURL, routePath, this.patToken, onEvent, (detail) => this.fileWatchRefusal(id, routePath, detail));
726
+ }
727
+ /**
728
+ * Explains a watch whose WebSocket handshake failed. The WebSocket API
729
+ * hides a refused handshake's status and body, so the same request is sent
730
+ * again as plain HTTP: toolboxd checks the runtime, the path and its limits
731
+ * before the upgrade, so it answers with the real reason (501 on Docker or
732
+ * containerd, 404 for a missing folder, 429 at the watch limit). That GET
733
+ * is a diagnosis, sent once, never a retry of the stream.
734
+ */
735
+ async fileWatchRefusal(id, routePath, detail) {
736
+ const fallback = new Error(`file watch failed to start${detail}`);
737
+ let response;
738
+ let text;
739
+ try {
740
+ response = await this.fetchFn(`${this.baseURL}${routePath}`, {
741
+ method: "GET",
742
+ headers: this.authHeaders(),
743
+ ...(this.signal ? { signal: this.signal } : {}),
744
+ });
745
+ text = await response.text();
746
+ }
747
+ catch {
748
+ return fallback;
749
+ }
750
+ let message;
751
+ try {
752
+ message = JSON.parse(text).error;
753
+ }
754
+ catch {
755
+ // Not JSON: a proxy's error page, or the upgrade's own refusal.
756
+ }
757
+ // An agent that predates file watching has no such route.
758
+ if (response.status === 404 && (response.headers.get(TOOLBOX_ROUTE_HEADER) === "none" || message === "not found")) {
759
+ return new MicroVMUnsupportedFeatureError(id, [FILE_WATCH_FEATURE]);
760
+ }
761
+ // toolboxd accepted the watch and only refused the plain-HTTP upgrade:
762
+ // the stream failed for some other reason, which `detail` holds.
763
+ if (response.ok || response.headers.has("sec-websocket-version")) {
764
+ return fallback;
765
+ }
766
+ return decodeError(new Response(text, { status: response.status, headers: response.headers }));
482
767
  }
483
768
  async createSession(id, options) {
484
769
  const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/sessions`, toApiCreateSessionOptions(options));
@@ -510,21 +795,256 @@ export class APIClient {
510
795
  attachSession(id, sessionID, options = {}) {
511
796
  return openSessionAttach(this.baseURL, this.versionPrefix, this.patToken, id, sessionID, options);
512
797
  }
513
- async uploadFile(id, targetPath, data) {
798
+ /**
799
+ * Plain and atomic uploads retry on 421/429/502/503/504; `append` and
800
+ * `createOnly` only on 429/503, because a second send would append twice
801
+ * or find the first one's file.
802
+ */
803
+ async uploadFile(id, targetPath, data, options = {}) {
804
+ const path = await this.uploadPath(id, options);
514
805
  const form = new FormData();
515
806
  form.set("path", targetPath);
516
807
  form.set("file", toBlob(data), basename(targetPath));
517
- const response = await this.request("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/files/upload`, { body: form });
808
+ const response = await this.request("POST", path, { body: form }, { idempotent: !(options.append || options.createOnly) });
518
809
  if (!response.ok) {
519
810
  throw await decodeError(response);
520
811
  }
521
812
  }
522
- async downloadFile(id, targetPath) {
813
+ /** The upload route with the write options as query, after the /version check they need. */
814
+ async uploadPath(id, options) {
815
+ const query = new URLSearchParams();
816
+ if (options.append)
817
+ query.set("append", "true");
818
+ if (options.createOnly)
819
+ query.set("create_only", "true");
820
+ if (options.atomic)
821
+ query.set("atomic", "true");
822
+ if (options.mode !== undefined)
823
+ query.set("mode", options.mode.toString(8));
824
+ if (query.size > 0) {
825
+ // An older agent ignores these; createOnly would replace a file.
826
+ await this.requireToolboxFeatures(id, FEATURE_WRITE_OPTIONS);
827
+ }
828
+ return `${this.versionPrefix}/sandboxes/${id}/toolbox/files/upload${query.size > 0 ? `?${query.toString()}` : ""}`;
829
+ }
830
+ /**
831
+ * Upload from a stream a piece at a time, so a large file never sits in
832
+ * memory. The options are {@link uploadFile}'s. The data is read as it is
833
+ * sent, so the request is sent once and never retried. Needs a `fetch` that
834
+ * can send a streamed body (Node.js 18 and later).
835
+ */
836
+ async uploadFileStream(id, targetPath, data, options = {}) {
837
+ const path = await this.uploadPath(id, options);
838
+ const boundary = `----aerolvm-${newIdempotencyKey()}`;
839
+ const encoder = new TextEncoder();
840
+ const head = encoder.encode(`--${boundary}\r\nContent-Disposition: form-data; name="path"\r\n\r\n${targetPath}\r\n` +
841
+ `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${basename(targetPath)}"\r\n` +
842
+ "Content-Type: application/octet-stream\r\n\r\n");
843
+ const tail = encoder.encode(`\r\n--${boundary}--\r\n`);
844
+ const parts = (async function* () {
845
+ yield head;
846
+ if (data instanceof ReadableStream) {
847
+ const reader = data.getReader();
848
+ for (;;) {
849
+ const { done, value } = await reader.read();
850
+ if (done)
851
+ break;
852
+ yield value;
853
+ }
854
+ }
855
+ else {
856
+ yield* data;
857
+ }
858
+ yield tail;
859
+ })();
860
+ const body = new ReadableStream({
861
+ async pull(controller) {
862
+ const next = await parts.next();
863
+ if (next.done)
864
+ controller.close();
865
+ else
866
+ controller.enqueue(next.value);
867
+ },
868
+ });
869
+ const init = {
870
+ body,
871
+ // Node's fetch sends a streamed body only with duplex "half".
872
+ duplex: "half",
873
+ headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
874
+ };
875
+ const response = await this.request("POST", path, init, { idempotent: false, sendOnce: true });
876
+ if (!response.ok) {
877
+ throw await decodeError(response);
878
+ }
879
+ }
880
+ /**
881
+ * Copy the local folder `localDir` into `remoteDir` in the sandbox (made if
882
+ * missing). The folder is packed as a gzipped tar and the sandbox unpacks it
883
+ * safely: no entry can land outside `remoteDir`. Symlinks are sent as links,
884
+ * hard-linked files as separate files; devices, pipes and sockets are
885
+ * skipped. The archive is built in memory and sent once, never retried.
886
+ * Node.js only.
887
+ */
888
+ async uploadDir(id, localDir, remoteDir) {
889
+ if (!(await stat(localDir)).isDirectory()) {
890
+ throw new Error(`${localDir} is not a folder`);
891
+ }
892
+ await this.requireToolboxFeatures(id, FEATURE_EXTRACT);
893
+ const archive = gzipSync(tarArchive(await folderEntries(localDir, "")));
894
+ const form = new FormData();
895
+ form.set("path", remoteDir);
896
+ form.set("file", new Blob([archive]), "upload.tar.gz");
897
+ const response = await this.request("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/files/upload?extract=true`, { body: form }, { idempotent: false, sendOnce: true });
898
+ if (!response.ok) {
899
+ throw await decodeError(response);
900
+ }
901
+ }
902
+ async downloadFile(id, targetPath, options = {}) {
903
+ const stream = await this.downloadFileStream(id, targetPath, options);
904
+ return new Uint8Array(await new Response(stream).arrayBuffer());
905
+ }
906
+ /**
907
+ * Open a file for reading a piece at a time, so a large file never sits in
908
+ * memory. With `maxBytes`, a larger file is refused before reading when the
909
+ * server states the size, and otherwise the stream errors as soon as more
910
+ * arrives. The request retries until the file starts to arrive.
911
+ */
912
+ async downloadFileStream(id, targetPath, options = {}) {
523
913
  const response = await this.request("GET", `${this.versionPrefix}/sandboxes/${id}/toolbox/files/download?path=${encodeURIComponent(targetPath)}`);
524
914
  if (!response.ok) {
525
915
  throw await decodeError(response);
526
916
  }
527
- return new Uint8Array(await response.arrayBuffer());
917
+ const body = response.body ?? new Response(new Uint8Array()).body;
918
+ const max = options.maxBytes;
919
+ if (max === undefined) {
920
+ return body;
921
+ }
922
+ const length = response.headers.get("content-length");
923
+ if (length !== null && Number(length) > max) {
924
+ await body.cancel().catch(() => undefined);
925
+ throw new MicroVMFileTooLargeError(targetPath, max);
926
+ }
927
+ let seen = 0;
928
+ return body.pipeThrough(new TransformStream({
929
+ transform(chunk, controller) {
930
+ if (seen + chunk.length > max) {
931
+ controller.enqueue(chunk.subarray(0, max - seen));
932
+ controller.error(new MicroVMFileTooLargeError(targetPath, max));
933
+ return;
934
+ }
935
+ seen += chunk.length;
936
+ controller.enqueue(chunk);
937
+ },
938
+ }));
939
+ }
940
+ // File verbs beyond upload and download (plans/agent-sandbox-feature-
941
+ // parity.md, F1), through the sandbox's toolbox. A sandbox whose agent
942
+ // predates them fails closed with MicroVMUnsupportedFeatureError instead of
943
+ // ignoring a safety option; WASM and V8 isolate sandboxes answer 501.
944
+ //
945
+ // Retries: listFiles, getFileInfo, fileExists, searchFiles, findInFiles,
946
+ // deleteFile and makeDir are safe to re-send and retry on 421, 429, 502,
947
+ // 503 and 504 (a retried delete or mkdir whose work is already done counts
948
+ // as success). moveFile and copyFile retry only on 429 and 503, because a
949
+ // lost reply may hide a move that happened.
950
+ toolboxPath(id, route, query) {
951
+ return `${this.versionPrefix}/sandboxes/${encodeURIComponent(id)}/toolbox${route}?${new URLSearchParams(query).toString()}`;
952
+ }
953
+ /** Sends a body-less file call and discards a success reply. */
954
+ async fileCall(method, path, options) {
955
+ const response = await this.request(method, path, {}, options);
956
+ if (!response.ok) {
957
+ throw await decodeError(response);
958
+ }
959
+ await response.body?.cancel().catch(() => undefined);
960
+ }
961
+ async listFiles(id, path, options = {}) {
962
+ const depth = options.depth ?? 1;
963
+ const limit = options.limit ?? DEFAULT_LIST_LIMIT;
964
+ if (depth < 1 || limit < 1) {
965
+ throw new Error("depth and limit must be at least 1");
966
+ }
967
+ await this.requireToolboxFeatures(id, FEATURE_FILE_VERBS);
968
+ const response = await this.request("GET", this.toolboxPath(id, "/files", { path, depth: String(depth), limit: String(limit), extended: "true" }));
969
+ if (!response.ok) {
970
+ throw await decodeError(response);
971
+ }
972
+ const items = ((await response.json()) ?? []);
973
+ const base = path.replace(/\/+$/, "");
974
+ return {
975
+ entries: items.map((item) => fromApiFileInfo(item, `${base}/${item.name ?? ""}`)),
976
+ truncated: response.headers.get("x-toolbox-truncated") === "true",
977
+ };
978
+ }
979
+ async getFileInfo(id, path) {
980
+ // extended=true only adds fields; an older agent that ignores it answers
981
+ // fields this still reads, so no /version check.
982
+ const item = await this.doJSON("GET", this.toolboxPath(id, "/files/info", { path, extended: "true" }));
983
+ return fromApiFileInfo(item ?? {}, path);
984
+ }
985
+ async fileExists(id, path) {
986
+ try {
987
+ await this.getFileInfo(id, path);
988
+ return true;
989
+ }
990
+ catch (err) {
991
+ if (isMissingFile(err)) {
992
+ return false;
993
+ }
994
+ throw err;
995
+ }
996
+ }
997
+ async deleteFile(id, path, options = {}) {
998
+ const query = { path };
999
+ if (options.recursive) {
1000
+ query.recursive = "true";
1001
+ }
1002
+ await this.fileCall("DELETE", this.toolboxPath(id, "/files", query), { doneCode: "file_not_found" });
1003
+ }
1004
+ async makeDir(id, path, options = {}) {
1005
+ const parents = options.parents ?? false;
1006
+ if (!parents) {
1007
+ // An older agent ignores parents=false and makes them anyway.
1008
+ await this.requireToolboxFeatures(id, FEATURE_FILE_VERBS);
1009
+ }
1010
+ const query = { path, parents: String(parents) };
1011
+ if (options.mode !== undefined) {
1012
+ query.mode = options.mode.toString(8);
1013
+ }
1014
+ await this.fileCall("POST", this.toolboxPath(id, "/files/folder", query), { doneCode: "folder_exists" });
1015
+ }
1016
+ async moveFile(id, source, destination, options = {}) {
1017
+ const overwrite = options.overwrite ?? false;
1018
+ if (!overwrite) {
1019
+ // An older agent ignores overwrite=false and replaces the destination.
1020
+ await this.requireToolboxFeatures(id, FEATURE_FILE_VERBS);
1021
+ }
1022
+ await this.fileCall("POST", this.toolboxPath(id, "/files/move", { source, destination, overwrite: String(overwrite) }), { idempotent: false });
1023
+ }
1024
+ async copyFile(id, source, destination, options = {}) {
1025
+ await this.requireToolboxFeatures(id, FEATURE_FILE_VERBS);
1026
+ const query = {
1027
+ source,
1028
+ destination,
1029
+ overwrite: String(options.overwrite ?? false),
1030
+ recursive: String(options.recursive ?? false),
1031
+ };
1032
+ await this.fileCall("POST", this.toolboxPath(id, "/files/copy", query), { idempotent: false });
1033
+ }
1034
+ async searchFiles(id, path, pattern) {
1035
+ const response = await this.doJSON("GET", this.toolboxPath(id, "/files/search", { path, pattern }));
1036
+ return Array.isArray(response?.files) ? response.files.map(String) : [];
1037
+ }
1038
+ async findInFiles(id, path, text) {
1039
+ const response = await this.doJSON("GET", this.toolboxPath(id, "/files/find", { path, pattern: text }));
1040
+ if (!Array.isArray(response)) {
1041
+ return [];
1042
+ }
1043
+ return response.map((m) => ({
1044
+ file: String(m.file ?? ""),
1045
+ line: Number(m.line ?? 0),
1046
+ content: String(m.content ?? ""),
1047
+ }));
528
1048
  }
529
1049
  /**
530
1050
  * Publish a sandbox container port. The chosen wire protocol is selected by
@@ -540,13 +1060,60 @@ export class APIClient {
540
1060
  * variant carries `host` / `hostPort` — everything else is in `url`.
541
1061
  */
542
1062
  async exposePort(id, port, options = {}) {
543
- const body = options.protocol ? { protocol: options.protocol } : undefined;
1063
+ // Only what the caller set: an omitted visibility keeps an exposed
1064
+ // port's gate, so a plain retry never opens a token port.
1065
+ const fields = {};
1066
+ if (options.protocol) {
1067
+ fields.protocol = options.protocol;
1068
+ }
1069
+ if (options.visibility) {
1070
+ fields.visibility = options.visibility;
1071
+ }
1072
+ const body = Object.keys(fields).length > 0 ? fields : undefined;
544
1073
  const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/ports/${port}`, body);
545
1074
  return fromApiExposePortResponse(response);
546
1075
  }
547
1076
  async unexposePort(id, port) {
548
1077
  await this.doJSON("DELETE", `${this.versionPrefix}/sandboxes/${id}/ports/${port}`);
549
1078
  }
1079
+ /**
1080
+ * Issue a signed token scoped to one sandbox (POST /v1/sandboxes/{id}/tokens).
1081
+ * Not idempotent by nature: every call makes a new token, and the server
1082
+ * stores none. It uses the normal retry tier: a retry after a lost answer
1083
+ * makes a second token that nobody holds, which simply expires.
1084
+ */
1085
+ async createSandboxToken(id, options) {
1086
+ const body = { expires_in: options.expiresIn };
1087
+ if (options.port !== undefined && options.port !== 0) {
1088
+ body.port = options.port;
1089
+ }
1090
+ if (options.pathPrefix) {
1091
+ body.path_prefix = options.pathPrefix;
1092
+ }
1093
+ if (options.methods && options.methods.length > 0) {
1094
+ body.methods = options.methods;
1095
+ }
1096
+ const r = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/tokens`, body);
1097
+ return {
1098
+ token: r.token,
1099
+ tokenID: r.token_id,
1100
+ sandboxID: r.sandbox_id,
1101
+ ...(r.port ? { port: r.port } : {}),
1102
+ ...(r.path_prefix ? { pathPrefix: r.path_prefix } : {}),
1103
+ ...(r.methods && r.methods.length > 0 ? { methods: r.methods } : {}),
1104
+ expiresAt: r.expires_at,
1105
+ };
1106
+ }
1107
+ /**
1108
+ * Make every token issued for one sandbox so far stop working
1109
+ * (POST /v1/sandboxes/{id}/tokens/revoke). A token issued after it works.
1110
+ * Safe to retry: a repeated call refuses the same tokens again (plus any
1111
+ * issued in between), so it uses the normal retry tier.
1112
+ */
1113
+ async revokeSandboxTokens(id) {
1114
+ const r = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/tokens/revoke`);
1115
+ return { sandboxID: r.sandbox_id, tokensNotBefore: r.tokens_not_before };
1116
+ }
550
1117
  /**
551
1118
  * Bind a custom hostname to a sandbox. The server lowercases the hostname
552
1119
  * and returns the post-add list of bindings (sorted by hostname). Calling
@@ -628,6 +1195,8 @@ export class APIClient {
628
1195
  params.set("cursor", options.cursor);
629
1196
  if (options.incarnationID)
630
1197
  params.set("incarnation_id", options.incarnationID);
1198
+ if (options.caller)
1199
+ params.set("caller", options.caller);
631
1200
  const query = params.toString();
632
1201
  const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/audit${query ? `?${query}` : ""}`);
633
1202
  return fromApiAuditPage(response);
@@ -698,6 +1267,68 @@ export class APIClient {
698
1267
  async deleteEgressProfile(name) {
699
1268
  await this.doJSON("DELETE", this.versioned(`/egress-profiles/${encodeURIComponent(name)}`));
700
1269
  }
1270
+ // Warm pools are PUT/GET/DELETE of one entry: every call is safe to retry,
1271
+ // so they keep the default (idempotent) retry tier.
1272
+ async putWarmPool(image, options) {
1273
+ return fromApiWarmPool(await this.doJSON("PUT", this.versioned(warmPoolPath(image)), {
1274
+ depth: options.depth ?? 0,
1275
+ min_depth: options.minDepth,
1276
+ max_depth: options.maxDepth,
1277
+ on_image_change: options.onImageChange,
1278
+ }));
1279
+ }
1280
+ async getWarmPool(image) {
1281
+ return fromApiWarmPool(await this.doJSON("GET", this.versioned(warmPoolPath(image))));
1282
+ }
1283
+ async listWarmPools() {
1284
+ const response = await this.doJSON("GET", this.versioned("/warm-pools"));
1285
+ return (response.pools ?? []).map(fromApiWarmPool);
1286
+ }
1287
+ async deleteWarmPool(image) {
1288
+ await this.doJSON("DELETE", this.versioned(warmPoolPath(image)));
1289
+ }
1290
+ async putIdentityRole(tag, options) {
1291
+ const response = await this.doJSON("PUT", this.identityRolePath(tag, options.team), {
1292
+ min_role: options.minRole,
1293
+ });
1294
+ return fromApiIdentityRole(response);
1295
+ }
1296
+ async listIdentityRoles(options = {}) {
1297
+ const params = new URLSearchParams();
1298
+ if (options.team)
1299
+ params.set("team", options.team);
1300
+ if (options.cursor)
1301
+ params.set("cursor", options.cursor);
1302
+ if (options.limit)
1303
+ params.set("limit", String(options.limit));
1304
+ const query = params.toString();
1305
+ const response = await this.doJSON("GET", this.versioned(`/identity/roles${query ? `?${query}` : ""}`));
1306
+ const list = { roles: (response.roles ?? []).map(fromApiIdentityRole) };
1307
+ if (response.next_cursor)
1308
+ list.nextCursor = response.next_cursor;
1309
+ return list;
1310
+ }
1311
+ async deleteIdentityRole(tag, options = {}) {
1312
+ await this.doJSON("DELETE", this.identityRolePath(tag, options.team));
1313
+ }
1314
+ identityRolePath(tag, team) {
1315
+ const path = `/identity/roles/${encodeURIComponent(tag)}`;
1316
+ return this.versioned(team ? `${path}?team=${encodeURIComponent(team)}` : path);
1317
+ }
1318
+ async putTeamQuota(team, options) {
1319
+ const response = await this.doJSON("PUT", this.versioned(`/teams/${encodeURIComponent(team)}/quota`), {
1320
+ max_sandboxes: options.maxSandboxes,
1321
+ max_cpu: options.maxCPU,
1322
+ max_memory_mb: options.maxMemoryMB,
1323
+ });
1324
+ return fromApiTeamQuota(response);
1325
+ }
1326
+ async getTeamQuota(team) {
1327
+ return fromApiTeamQuota(await this.doJSON("GET", this.versioned(`/teams/${encodeURIComponent(team)}/quota`)));
1328
+ }
1329
+ async deleteTeamQuota(team) {
1330
+ await this.doJSON("DELETE", this.versioned(`/teams/${encodeURIComponent(team)}/quota`));
1331
+ }
701
1332
  async createTemplate(options) {
702
1333
  const response = await this.doJSON("POST", this.versioned("/templates"), {
703
1334
  id: options.id,
@@ -785,7 +1416,7 @@ export class APIClient {
785
1416
  await this.doJSON("DELETE", this.versioned(`/tokens/${encodeURIComponent(trimmed)}`));
786
1417
  }
787
1418
  wrap(sandbox) {
788
- return new SandboxResource(this, fromApiSandbox(sandbox));
1419
+ return new SandboxResource(this.root ?? this, fromApiSandbox(sandbox));
789
1420
  }
790
1421
  async doJSON(method, path, body, options) {
791
1422
  const init = { headers: { ...options?.headers } };
@@ -828,25 +1459,37 @@ export class APIClient {
828
1459
  * {@link retryDelayMs}). The client's `signal` ends the request and any
829
1460
  * wait.
830
1461
  */
831
- async request(method, path, init = {}, options = {}) {
832
- const headers = new Headers(init.headers);
1462
+ authHeaders(base) {
1463
+ const headers = new Headers(base);
833
1464
  if (this.patToken !== "") {
834
1465
  headers.set("Authorization", `Bearer ${this.patToken}`);
835
1466
  }
836
1467
  if (this.team !== "") {
837
1468
  headers.set(TEAM_HEADER, this.team);
838
1469
  }
1470
+ return headers;
1471
+ }
1472
+ async request(method, path, init = {}, options = {}) {
1473
+ const headers = this.authHeaders(init.headers);
839
1474
  const url = `${this.baseURL}${path}`;
840
1475
  const signal = init.signal ?? this.signal;
841
1476
  const requestInit = { ...init, method, headers, ...(signal ? { signal } : {}) };
842
- const { maxRetries } = this.retryConfig;
1477
+ const maxRetries = options.sendOnce ? 0 : this.retryConfig.maxRetries;
843
1478
  const idempotent = options.idempotent ?? true;
844
1479
  const retryableStatusCodes = idempotent ? RETRYABLE_STATUS_CODES : REFUSED_STATUS_CODES;
845
1480
  const isRetryableError = idempotent ? isTransientTransportError : isUnsentTransportError;
1481
+ let mayHaveRun = false;
846
1482
  for (let attempt = 0;; attempt++) {
847
1483
  let retryAfter = null;
848
1484
  try {
849
1485
  const response = await this.fetchFn(url, requestInit);
1486
+ if (mayHaveRun && options.doneCode && !response.ok && (await errorCode(response)) === options.doneCode) {
1487
+ response.body?.cancel().catch(() => undefined);
1488
+ return new Response(null, { status: 204 });
1489
+ }
1490
+ if (response.status === 502 || response.status === 504) {
1491
+ mayHaveRun = true;
1492
+ }
850
1493
  if (!retryableStatusCodes.has(response.status) || attempt >= maxRetries) {
851
1494
  return response;
852
1495
  }
@@ -862,24 +1505,56 @@ export class APIClient {
862
1505
  if (signal?.aborted || !isRetryableError(error) || attempt >= maxRetries) {
863
1506
  throw error;
864
1507
  }
1508
+ mayHaveRun ||= !isUnsentTransportError(error);
865
1509
  }
866
1510
  await sleep(retryDelayMs(this.retryConfig, attempt, retryAfter, Date.now(), Math.random()), signal);
867
1511
  }
868
1512
  }
869
1513
  }
870
1514
  export class SandboxResource {
871
- client;
1515
+ /**
1516
+ * When true, leaving an `await using` block deletes the sandbox (see
1517
+ * `[Symbol.asyncDispose]`). Off by default, so the block ends without
1518
+ * touching it; set it here or with `destroyOnExit` on `create` and
1519
+ * `getOrCreate`.
1520
+ */
1521
+ destroyOnExit = false;
1522
+ disposed = false;
1523
+ /** The client for one call: the real one, or a view carrying the call's signal. */
1524
+ api(call) {
1525
+ return this.client.withSignal(call?.signal);
1526
+ }
872
1527
  constructor(client, sandbox) {
873
- this.client = client;
1528
+ // Not enumerable: it holds the token, so it must never reach toJSON,
1529
+ // JSON.stringify or a log of the sandbox.
1530
+ Object.defineProperty(this, "client", { value: client, enumerable: false });
874
1531
  this.apply(sandbox);
875
1532
  }
876
- async refresh() {
877
- const updated = await this.client.get(this.id);
1533
+ /**
1534
+ * Called when an `await using` block holding this sandbox ends. Deletes the
1535
+ * sandbox only when {@link destroyOnExit} is true; otherwise does nothing.
1536
+ * A sandbox that is already gone counts as deleted, and a second call does
1537
+ * nothing.
1538
+ */
1539
+ async [Symbol.asyncDispose]() {
1540
+ if (!this.destroyOnExit || this.disposed)
1541
+ return;
1542
+ try {
1543
+ await this.client.destroy(this.id);
1544
+ }
1545
+ catch (err) {
1546
+ if (!(err instanceof MicroVMNotFoundError))
1547
+ throw err;
1548
+ }
1549
+ this.disposed = true;
1550
+ }
1551
+ async refresh(call) {
1552
+ const updated = await this.api(call).get(this.id);
878
1553
  this.apply(updated.toJSON());
879
1554
  return this;
880
1555
  }
881
- async exec(command) {
882
- return this.client.exec(this.id, typeof command === "string" ? { command } : command);
1556
+ async exec(command, call) {
1557
+ return this.api(call).exec(this.id, typeof command === "string" ? { command } : command);
883
1558
  }
884
1559
  /**
885
1560
  * Read this sandbox's clone-generation token. The token changes whenever the
@@ -887,50 +1562,122 @@ export class SandboxResource {
887
1562
  * clone (e.g. to re-run setup). It does NOT reseed in-guest PRNGs — see the
888
1563
  * "Randomness in cloned sandboxes" docs page for the in-guest pattern.
889
1564
  */
890
- async cloneGeneration() {
891
- return this.client.cloneGeneration(this.id);
1565
+ async cloneGeneration(call) {
1566
+ return this.api(call).cloneGeneration(this.id);
892
1567
  }
893
1568
  execStream(options) {
894
1569
  return this.client.execStream(this.id, options);
895
1570
  }
896
- async createSession(options) {
897
- return this.client.createSession(this.id, options);
1571
+ /**
1572
+ * Watch a folder in this sandbox for changes; see
1573
+ * {@link APIClient.watchFiles}. gVisor and Firecracker sandboxes only.
1574
+ */
1575
+ async watchFiles(path, onEvent, options = {}) {
1576
+ return this.client.watchFiles(this.id, path, onEvent, options);
1577
+ }
1578
+ async createSession(options, call) {
1579
+ return this.api(call).createSession(this.id, options);
898
1580
  }
899
- async listSessions() {
900
- return this.client.listSessions(this.id);
1581
+ async listSessions(call) {
1582
+ return this.api(call).listSessions(this.id);
901
1583
  }
902
- async getSession(sessionID) {
903
- return this.client.getSession(this.id, sessionID);
1584
+ async getSession(sessionID, call) {
1585
+ return this.api(call).getSession(this.id, sessionID);
904
1586
  }
905
- async deleteSession(sessionID) {
906
- await this.client.deleteSession(this.id, sessionID);
1587
+ async deleteSession(sessionID, call) {
1588
+ await this.api(call).deleteSession(this.id, sessionID);
907
1589
  }
908
- async signalSession(sessionID, signal) {
909
- await this.client.signalSession(this.id, sessionID, signal);
1590
+ async signalSession(sessionID, signal, call) {
1591
+ await this.api(call).signalSession(this.id, sessionID, signal);
910
1592
  }
911
- async resizeSession(sessionID, cols, rows) {
912
- await this.client.resizeSession(this.id, sessionID, cols, rows);
1593
+ async resizeSession(sessionID, cols, rows, call) {
1594
+ await this.api(call).resizeSession(this.id, sessionID, cols, rows);
913
1595
  }
914
- async sessionLog(sessionID) {
915
- return this.client.sessionLog(this.id, sessionID);
1596
+ async sessionLog(sessionID, call) {
1597
+ return this.api(call).sessionLog(this.id, sessionID);
916
1598
  }
917
- async sessionRecording(sessionID) {
918
- return this.client.sessionRecording(this.id, sessionID);
1599
+ async sessionRecording(sessionID, call) {
1600
+ return this.api(call).sessionRecording(this.id, sessionID);
919
1601
  }
920
1602
  attachSession(sessionID, options = {}) {
921
1603
  return this.client.attachSession(this.id, sessionID, options);
922
1604
  }
923
- async uploadFile(targetPath, data) {
924
- await this.client.uploadFile(this.id, targetPath, data);
1605
+ /** Copy a local folder into the sandbox (Node.js only). See {@link APIClient.uploadDir}. */
1606
+ async uploadDir(localDir, remoteDir, call) {
1607
+ await this.api(call).uploadDir(this.id, localDir, remoteDir);
1608
+ }
1609
+ /** Upload a file. See {@link UploadFileOptions} for append, create-only, atomic and mode. */
1610
+ async uploadFile(targetPath, data, options = {}, call) {
1611
+ await this.api(call).uploadFile(this.id, targetPath, data, options);
1612
+ }
1613
+ async downloadFile(targetPath, options = {}, call) {
1614
+ return this.api(call).downloadFile(this.id, targetPath, options);
1615
+ }
1616
+ /** Read a file as a stream. See {@link APIClient.downloadFileStream}. */
1617
+ async downloadFileStream(targetPath, options = {}, call) {
1618
+ return this.api(call).downloadFileStream(this.id, targetPath, options);
1619
+ }
1620
+ /** Upload from a stream, sent once. See {@link APIClient.uploadFileStream}. */
1621
+ async uploadFileStream(targetPath, data, options = {}, call) {
1622
+ await this.api(call).uploadFileStream(this.id, targetPath, data, options);
1623
+ }
1624
+ /** List a folder. See {@link ListFilesOptions}; `truncated` means more entries exist. */
1625
+ async listFiles(path, options = {}, call) {
1626
+ return this.api(call).listFiles(this.id, path, options);
1627
+ }
1628
+ /** Describe one path. A symlink is followed. */
1629
+ async getFileInfo(path, call) {
1630
+ return this.api(call).getFileInfo(this.id, path);
1631
+ }
1632
+ /** Whether a path exists. A missing sandbox throws; it is not `false`. */
1633
+ async fileExists(path, call) {
1634
+ return this.api(call).fileExists(this.id, path);
925
1635
  }
926
- async downloadFile(targetPath) {
927
- return this.client.downloadFile(this.id, targetPath);
1636
+ /** Delete a file, or a folder and its contents with `recursive: true`. */
1637
+ async deleteFile(path, options = {}, call) {
1638
+ await this.api(call).deleteFile(this.id, path, options);
928
1639
  }
929
- async exposePort(port, options) {
930
- return this.client.exposePort(this.id, port, options);
1640
+ /** Make a folder; with `parents: true`, like `mkdir -p`. */
1641
+ async makeDir(path, options = {}, call) {
1642
+ await this.api(call).makeDir(this.id, path, options);
931
1643
  }
932
- async unexposePort(port) {
933
- await this.client.unexposePort(this.id, port);
1644
+ /** Move or rename a file or folder. Without `overwrite`, an existing destination is refused (409). */
1645
+ async moveFile(source, destination, options = {}, call) {
1646
+ await this.api(call).moveFile(this.id, source, destination, options);
1647
+ }
1648
+ /** Copy a file, or a folder with `recursive: true`. */
1649
+ async copyFile(source, destination, options = {}, call) {
1650
+ await this.api(call).copyFile(this.id, source, destination, options);
1651
+ }
1652
+ /** Paths under `path` whose name matches a glob such as `*.ts`. */
1653
+ async searchFiles(path, pattern, call) {
1654
+ return this.api(call).searchFiles(this.id, path, pattern);
1655
+ }
1656
+ /** Each line under `path` that contains `text` (plain text). */
1657
+ async findInFiles(path, text, call) {
1658
+ return this.api(call).findInFiles(this.id, path, text);
1659
+ }
1660
+ async exposePort(port, options, call) {
1661
+ return this.api(call).exposePort(this.id, port, options);
1662
+ }
1663
+ async unexposePort(port, call) {
1664
+ await this.api(call).unexposePort(this.id, port);
1665
+ }
1666
+ /**
1667
+ * Issue a signed token for this sandbox only, to hand to a browser or an
1668
+ * end user. See {@link CreateSandboxTokenOptions}. Each call makes a new
1669
+ * token; the server keeps no copy.
1670
+ */
1671
+ async createToken(options, call) {
1672
+ return this.api(call).createSandboxToken(this.id, options);
1673
+ }
1674
+ /**
1675
+ * Make every token issued for this sandbox so far stop working, toolbox
1676
+ * and port tokens alike. A token made after the call works. Safe to call
1677
+ * again.
1678
+ */
1679
+ async revokeTokens(call) {
1680
+ return this.api(call).revokeSandboxTokens(this.id);
934
1681
  }
935
1682
  /**
936
1683
  * Per-sandbox custom-hostname bindings. Exposed as a namespaced accessor
@@ -944,51 +1691,51 @@ export class SandboxResource {
944
1691
  const client = this.client;
945
1692
  const id = this.id;
946
1693
  return {
947
- add: (hostname, options) => client.addCustomDomain(id, hostname, options),
948
- remove: (hostname) => client.removeCustomDomain(id, hostname),
949
- list: () => client.listCustomDomains(id),
950
- dns: () => client.customDomainDNS(id),
1694
+ add: (hostname, options, call) => client.withSignal(call?.signal).addCustomDomain(id, hostname, options),
1695
+ remove: (hostname, call) => client.withSignal(call?.signal).removeCustomDomain(id, hostname),
1696
+ list: (call) => client.withSignal(call?.signal).listCustomDomains(id),
1697
+ dns: (call) => client.withSignal(call?.signal).customDomainDNS(id),
951
1698
  };
952
1699
  }
953
- async createSnapshot(name, options = {}) {
954
- return this.client.createSnapshot(this.id, name, options);
1700
+ async createSnapshot(name, options = {}, call) {
1701
+ return this.api(call).createSnapshot(this.id, name, options);
955
1702
  }
956
- async start() {
957
- const updated = await this.client.start(this.id);
1703
+ async start(call) {
1704
+ const updated = await this.api(call).start(this.id);
958
1705
  this.apply(updated.toJSON());
959
1706
  return this;
960
1707
  }
961
- async stop() {
962
- const updated = await this.client.stop(this.id);
1708
+ async stop(call) {
1709
+ const updated = await this.api(call).stop(this.id);
963
1710
  this.apply(updated.toJSON());
964
1711
  return this;
965
1712
  }
966
- async destroy() {
967
- await this.client.destroy(this.id);
1713
+ async destroy(call) {
1714
+ await this.api(call).destroy(this.id);
968
1715
  }
969
- async resize(options) {
970
- const updated = await this.client.resize(this.id, options);
1716
+ async resize(options, call) {
1717
+ const updated = await this.api(call).resize(this.id, options);
971
1718
  this.apply(updated.toJSON());
972
1719
  return this;
973
1720
  }
974
- async updateLifecycle(lifecycle) {
975
- const updated = await this.client.updateLifecycle(this.id, lifecycle);
1721
+ async updateLifecycle(lifecycle, call) {
1722
+ const updated = await this.api(call).updateLifecycle(this.id, lifecycle);
976
1723
  this.apply(updated.toJSON());
977
1724
  return this;
978
1725
  }
979
- async getNetworkUsage() {
980
- return this.client.getNetworkUsage(this.id);
1726
+ async getNetworkUsage(call) {
1727
+ return this.api(call).getNetworkUsage(this.id);
981
1728
  }
982
- async setNetworkLimits(options) {
983
- return this.client.setNetworkLimits(this.id, options);
1729
+ async setNetworkLimits(options, call) {
1730
+ return this.api(call).setNetworkLimits(this.id, options);
984
1731
  }
985
1732
  /**
986
1733
  * Replaces this sandbox's egress policy while it runs. The call returns
987
1734
  * once the new policy is enforced; sending the same policy again is a
988
1735
  * no-op, so it is safe to retry.
989
1736
  */
990
- async setNetworkPolicy(options) {
991
- const policy = await this.client.setNetworkPolicy(this.id, options);
1737
+ async setNetworkPolicy(options, call) {
1738
+ const policy = await this.api(call).setNetworkPolicy(this.id, options);
992
1739
  this.networkBlockAll = policy.networkBlockAll;
993
1740
  this.egressStatus = policy.egressStatus;
994
1741
  this.egressProfiles = policy.egressProfiles.length ? policy.egressProfiles : undefined;
@@ -1001,19 +1748,21 @@ export class SandboxResource {
1001
1748
  * would have allowed it. A recording stays readable after the sandbox
1002
1749
  * switches to enforce, until it is destroyed.
1003
1750
  */
1004
- async learned() {
1005
- return this.client.getNetworkLearned(this.id);
1751
+ async learned(call) {
1752
+ return this.api(call).getNetworkLearned(this.id);
1006
1753
  }
1007
1754
  /**
1008
1755
  * Reads this sandbox's audit log: outbound connections and egress denials
1009
1756
  * (`kind: "egress"`), and secret reads. Denials carry `result: "failure"`
1010
1757
  * and the policy `reason`.
1011
1758
  */
1012
- async audit(options) {
1013
- return this.client.getAudit(this.id, options);
1759
+ async audit(options, call) {
1760
+ return this.api(call).getAudit(this.id, options);
1014
1761
  }
1015
1762
  toJSON() {
1016
- return cloneSandbox(this);
1763
+ // Only the sandbox's own fields, not the SDK's flags on this object.
1764
+ const { destroyOnExit: _destroyOnExit, disposed: _disposed, ...data } = this;
1765
+ return cloneSandbox(data);
1017
1766
  }
1018
1767
  apply(sandbox) {
1019
1768
  Object.assign(this, cloneSandbox(sandbox));
@@ -1045,6 +1794,7 @@ function toApiCreateOptions(options) {
1045
1794
  platform_volumes: options.platformVolumes?.map(toApiPlatformVolumeMount),
1046
1795
  lifecycle: options.lifecycle ? toApiLifecycle(options.lifecycle) : undefined,
1047
1796
  failover: options.failover ? toApiFailover(options.failover) : undefined,
1797
+ placement: options.placement ? { zone: options.placement.zone, labels: options.placement.labels } : undefined,
1048
1798
  runtime: options.runtime,
1049
1799
  durability: options.durability,
1050
1800
  module_ref: options.moduleRef,
@@ -1052,6 +1802,7 @@ function toApiCreateOptions(options) {
1052
1802
  custom_domains: options.customDomains,
1053
1803
  gpus: options.gpus ? toApiGPUOptions(options.gpus) : undefined,
1054
1804
  template_id: options.templateId,
1805
+ identity: options.identity ? toApiIdentity(options.identity) : undefined,
1055
1806
  };
1056
1807
  }
1057
1808
  function toApiGPUOptions(gpus) {
@@ -1085,9 +1836,12 @@ function toApiResizeOptions(options) {
1085
1836
  function toApiExecRequest(request) {
1086
1837
  return {
1087
1838
  command: request.command,
1839
+ argv: request.argv,
1088
1840
  workdir: request.workDir,
1089
1841
  env: request.env,
1090
1842
  timeout_seconds: request.timeoutSeconds,
1843
+ max_stdout_bytes: request.maxStdoutBytes,
1844
+ max_stderr_bytes: request.maxStderrBytes,
1091
1845
  };
1092
1846
  }
1093
1847
  function toApiCreateSessionOptions(options) {
@@ -1109,6 +1863,9 @@ function toApiLifecycle(lifecycle) {
1109
1863
  stop_at_age: lifecycle.stopAtAge,
1110
1864
  destroy_at_age: lifecycle.destroyAtAge,
1111
1865
  serverless: lifecycle.serverless,
1866
+ restart_on_failure: lifecycle.restartOnFailure,
1867
+ destroy_after_exit_for: lifecycle.destroyAfterExitFor,
1868
+ destroy_at: lifecycle.destroyAt instanceof Date ? lifecycle.destroyAt.toISOString() : lifecycle.destroyAt,
1112
1869
  };
1113
1870
  }
1114
1871
  function toApiFailover(failover) {
@@ -1144,6 +1901,9 @@ function fromApiSandbox(sandbox) {
1144
1901
  updatedAt: sandbox.updated_at,
1145
1902
  lastActiveAt: sandbox.last_active_at,
1146
1903
  lastError: sandbox.last_error,
1904
+ statusReason: (sandbox.status_reason || undefined),
1905
+ // 0 is a real exit code; only an absent or null one is left out.
1906
+ exitCode: sandbox.exit_code ?? undefined,
1147
1907
  containerCommand: sandbox.container_command,
1148
1908
  lifecycle: fromApiLifecycle(sandbox.lifecycle),
1149
1909
  failover: fromApiFailover(sandbox.failover),
@@ -1152,6 +1912,7 @@ function fromApiSandbox(sandbox) {
1152
1912
  moduleRef: sandbox.module_ref,
1153
1913
  moduleDigest: sandbox.module_digest,
1154
1914
  tenantId: sandbox.tenant_id,
1915
+ identity: fromApiSandboxIdentity(sandbox.identity),
1155
1916
  };
1156
1917
  }
1157
1918
  // normalizeRuntime narrows the wire-level string to the union we expose, while
@@ -1257,6 +2018,7 @@ function fromApiExposedPort(port) {
1257
2018
  port: port.port,
1258
2019
  publicURL: port.public_url,
1259
2020
  createdAt: port.created_at,
2021
+ ...(port.visibility === "token" ? { visibility: "token" } : {}),
1260
2022
  };
1261
2023
  }
1262
2024
  function fromApiExposePortResponse(response) {
@@ -1271,7 +2033,9 @@ function fromApiExposePortResponse(response) {
1271
2033
  case "tls":
1272
2034
  return { protocol: "tls", url: response.public_url };
1273
2035
  case "http":
1274
- return { protocol: "http", url: response.public_url };
2036
+ return response.visibility === "token"
2037
+ ? { protocol: "http", url: response.public_url, visibility: "token" }
2038
+ : { protocol: "http", url: response.public_url };
1275
2039
  }
1276
2040
  }
1277
2041
  function fromApiExecResult(result) {
@@ -1280,6 +2044,10 @@ function fromApiExecResult(result) {
1280
2044
  stderr: result.stderr,
1281
2045
  exitCode: result.exit_code,
1282
2046
  durationMS: result.duration_ms,
2047
+ timedOut: result.timed_out ?? false,
2048
+ stdoutTruncated: result.stdout_truncated ?? false,
2049
+ stderrTruncated: result.stderr_truncated ?? false,
2050
+ signal: result.signal || undefined,
1283
2051
  };
1284
2052
  }
1285
2053
  function fromApiHealthStatus(status) {
@@ -1313,6 +2081,7 @@ function toApiPlatformVolumeMount(volume) {
1313
2081
  name: volume.name,
1314
2082
  path: volume.path,
1315
2083
  read_only: volume.readOnly,
2084
+ delete_with_sandbox: volume.deleteWithSandbox,
1316
2085
  };
1317
2086
  }
1318
2087
  function fromApiMountSpecRedacted(mount) {
@@ -1385,6 +2154,12 @@ function fromApiLifecycle(lifecycle) {
1385
2154
  if (lifecycle?.serverless) {
1386
2155
  result.serverless = true;
1387
2156
  }
2157
+ if (lifecycle?.restart_on_failure) {
2158
+ result.restartOnFailure = true;
2159
+ }
2160
+ if (lifecycle?.destroy_after_exit_for !== undefined) {
2161
+ result.destroyAfterExitFor = lifecycle.destroy_after_exit_for;
2162
+ }
1388
2163
  return result;
1389
2164
  }
1390
2165
  function fromApiFailover(failover) {
@@ -1439,6 +2214,7 @@ function cloneSandbox(sandbox) {
1439
2214
  containerCommand: sandbox.containerCommand ? [...sandbox.containerCommand] : undefined,
1440
2215
  lifecycle: { ...sandbox.lifecycle },
1441
2216
  failover: sandbox.failover ? { ...sandbox.failover } : undefined,
2217
+ identity: sandbox.identity ? { ...sandbox.identity } : undefined,
1442
2218
  };
1443
2219
  }
1444
2220
  function toBlob(data) {
@@ -1453,23 +2229,91 @@ function toBlob(data) {
1453
2229
  }
1454
2230
  return new Blob([Uint8Array.from(data)]);
1455
2231
  }
2232
+ /** The folder's contents as tar entries, names relative to it, sorted. */
2233
+ async function folderEntries(root, prefix) {
2234
+ const entries = [];
2235
+ const names = (await readdir(join(root, prefix))).sort();
2236
+ for (const name of names) {
2237
+ const rel = prefix === "" ? name : `${prefix}/${name}`;
2238
+ const full = join(root, rel);
2239
+ const info = await lstat(full);
2240
+ const base = { name: rel, mode: info.mode & 0o7777, mtime: Math.floor(info.mtimeMs / 1000) };
2241
+ if (info.isSymbolicLink()) {
2242
+ entries.push({ ...base, type: "symlink", linkname: await readlink(full) });
2243
+ }
2244
+ else if (info.isDirectory()) {
2245
+ entries.push({ ...base, type: "dir" }, ...(await folderEntries(root, rel)));
2246
+ }
2247
+ else if (info.isFile()) {
2248
+ entries.push({ ...base, type: "file", data: await readFile(full) });
2249
+ }
2250
+ // Devices, pipes and sockets are skipped: the sandbox refuses them.
2251
+ }
2252
+ return entries;
2253
+ }
2254
+ /** The `code` of a JSON error reply, read from a clone so the body stays readable. */
2255
+ async function errorCode(response) {
2256
+ try {
2257
+ const payload = (await response.clone().json());
2258
+ return typeof payload.code === "string" ? payload.code : undefined;
2259
+ }
2260
+ catch {
2261
+ return undefined;
2262
+ }
2263
+ }
2264
+ /**
2265
+ * A missing file, not a missing sandbox (also a 404). Current agents say
2266
+ * file_not_found; older ones send no code, but their message is the OS
2267
+ * error, which ends "no such file or directory".
2268
+ */
2269
+ function isMissingFile(err) {
2270
+ if (!(err instanceof MicroVMApiError) || err.status !== 404) {
2271
+ return false;
2272
+ }
2273
+ return err.code === "file_not_found" || (err.code === undefined && err.message.endsWith("no such file or directory"));
2274
+ }
2275
+ /**
2276
+ * An older agent ignores extended=true and sends only Daytona's fields (a
2277
+ * 32-bit size, no path), so each extended field falls back.
2278
+ */
2279
+ function fromApiFileInfo(item, fallbackPath) {
2280
+ const info = {
2281
+ name: item.name ?? "",
2282
+ path: item.path || fallbackPath,
2283
+ isDir: item.isDir ?? false,
2284
+ size: item.size_bytes ?? item.size ?? 0,
2285
+ mode: item.mode ?? "",
2286
+ permissions: item.permissions ?? "",
2287
+ owner: item.owner ?? "",
2288
+ group: item.group ?? "",
2289
+ modTime: item.modTime ?? "",
2290
+ };
2291
+ if (typeof item.symlink_target === "string") {
2292
+ info.symlinkTarget = item.symlink_target;
2293
+ }
2294
+ return info;
2295
+ }
1456
2296
  async function decodeError(response) {
1457
2297
  const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"), Date.now());
1458
2298
  try {
1459
2299
  const payload = (await response.json());
1460
2300
  if (typeof payload.error === "string" && payload.error !== "") {
1461
2301
  const code = typeof payload.code === "string" && payload.code !== "" ? payload.code : undefined;
1462
- return new MicroVMApiError(response.status, payload.error, { code, retryAfterMs });
2302
+ return apiErrorFor(response.status, payload.error, { code, retryAfterMs });
1463
2303
  }
1464
2304
  }
1465
2305
  catch {
1466
2306
  // Not JSON (a proxy's error page) or no body: fall through to the
1467
2307
  // status-based message, with no code.
1468
2308
  }
1469
- return new MicroVMApiError(response.status, `request failed with status ${response.status}`, { retryAfterMs });
2309
+ return apiErrorFor(response.status, `request failed with status ${response.status}`, { retryAfterMs });
1470
2310
  }
1471
2311
  const STREAM_PREFIX_STDOUT = 0x01;
1472
2312
  const STREAM_PREFIX_STDERR = 0x02;
2313
+ // toolboxd marks a 404 that means "no such route" with this header, as
2314
+ // opposed to a route that answered 404 for a missing file.
2315
+ const TOOLBOX_ROUTE_HEADER = "x-toolbox-route";
2316
+ const FILE_WATCH_FEATURE = "files.watch";
1473
2317
  // Pull whatever diagnostic detail the runtime gave us off a WebSocket "error"
1474
2318
  // event. Node 22's native WebSocket fires an ErrorEvent with `.error` /
1475
2319
  // `.message`; browsers' base WebSocket fires a bare Event. Returns "" when
@@ -1505,7 +2349,7 @@ function toWebSocketBinaryFrame(data) {
1505
2349
  frame.set(bytes);
1506
2350
  return frame;
1507
2351
  }
1508
- function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options) {
2352
+ function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options, ready) {
1509
2353
  const wsURL = baseURL.replace(/^http/, "ws") + `${versionPrefix}/sandboxes/${encodeURIComponent(sandboxID)}/toolbox/process/exec/stream`;
1510
2354
  const WS = globalThis.WebSocket;
1511
2355
  if (!WS) {
@@ -1537,17 +2381,51 @@ function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options) {
1537
2381
  if (resolved)
1538
2382
  return;
1539
2383
  resolved = true;
1540
- exitReject?.(new Error(msg));
2384
+ exitReject?.(typeof msg === "string" ? new Error(msg) : msg);
1541
2385
  };
1542
- ws.addEventListener("open", () => {
2386
+ // Frames the caller sends after the socket opened but before a delayed
2387
+ // start message (argv's feature check) wait here, so the start message is
2388
+ // always first. Before the socket opens, send fails as it always has.
2389
+ let started = false;
2390
+ const pending = [];
2391
+ const send = (data) => {
2392
+ if (!started && ws.readyState === 1) {
2393
+ pending.push(data);
2394
+ return;
2395
+ }
2396
+ ws.send(data);
2397
+ };
2398
+ const sendStart = () => {
1543
2399
  ws.send(JSON.stringify({
1544
2400
  command: options.command,
2401
+ argv: options.argv,
1545
2402
  workdir: options.workdir,
1546
2403
  env: options.env,
1547
2404
  tty: options.tty ?? false,
1548
2405
  cols: options.cols ?? 0,
1549
2406
  rows: options.rows ?? 0,
1550
2407
  }));
2408
+ started = true;
2409
+ for (const data of pending.splice(0)) {
2410
+ ws.send(data);
2411
+ }
2412
+ };
2413
+ // The check can fail before the socket opens; mark it handled now so
2414
+ // Node does not report an unhandled rejection, and act on it at "open".
2415
+ ready?.catch(() => undefined);
2416
+ ws.addEventListener("open", () => {
2417
+ if (!ready) {
2418
+ sendStart();
2419
+ return;
2420
+ }
2421
+ ready.then(() => {
2422
+ if (!resolved) {
2423
+ sendStart();
2424
+ }
2425
+ }, (err) => {
2426
+ failWith(err instanceof Error ? err : new Error(String(err)));
2427
+ ws.close();
2428
+ });
1551
2429
  });
1552
2430
  ws.addEventListener("message", (event) => {
1553
2431
  if (typeof event.data === "string") {
@@ -1595,18 +2473,18 @@ function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options) {
1595
2473
  failWith(detail !== "" ? `websocket error: ${detail}` : "websocket error");
1596
2474
  });
1597
2475
  const sendBinary = (data) => {
1598
- ws.send(toWebSocketBinaryFrame(data));
2476
+ send(toWebSocketBinaryFrame(data));
1599
2477
  };
1600
2478
  return {
1601
2479
  write: sendBinary,
1602
2480
  resize(cols, rows) {
1603
- ws.send(JSON.stringify({ type: "resize", cols, rows }));
2481
+ send(JSON.stringify({ type: "resize", cols, rows }));
1604
2482
  },
1605
2483
  signal(name) {
1606
- ws.send(JSON.stringify({ type: "signal", signal: name }));
2484
+ send(JSON.stringify({ type: "signal", signal: name }));
1607
2485
  },
1608
2486
  close() {
1609
- ws.send(JSON.stringify({ type: "close" }));
2487
+ send(JSON.stringify({ type: "close" }));
1610
2488
  },
1611
2489
  done,
1612
2490
  };
@@ -1711,3 +2589,152 @@ function openSessionAttach(baseURL, versionPrefix, patToken, sandboxID, sessionI
1711
2589
  done,
1712
2590
  };
1713
2591
  }
2592
+ function fromApiFileEvent(raw) {
2593
+ const e = (raw ?? {});
2594
+ const event = {
2595
+ type: typeof e.type === "string" ? e.type : "",
2596
+ path: typeof e.path === "string" ? e.path : "",
2597
+ isDir: e.is_dir === true,
2598
+ };
2599
+ if (typeof e.reason === "string" && e.reason !== "") {
2600
+ event.reason = e.reason;
2601
+ }
2602
+ return event;
2603
+ }
2604
+ // openFileWatch opens toolboxd's /files/watch stream and resolves with its
2605
+ // handle once the "start" frame says the watch is in place. `refusal`
2606
+ // explains a handshake that never opened.
2607
+ // How long a file watch waits for the agent to say the watch is armed.
2608
+ export const WATCH_START_TIMEOUT_MS = 60_000;
2609
+ function openFileWatch(baseURL, routePath, patToken, onEvent, refusal) {
2610
+ const WS = globalThis.WebSocket;
2611
+ if (!WS) {
2612
+ return Promise.reject(new Error("WebSocket is not available in this runtime — Node 22+ or a browser is required"));
2613
+ }
2614
+ // The PAT rides the subprotocol, as on execStream: browsers cannot set
2615
+ // Authorization on a WebSocket handshake.
2616
+ const ws = new WS(baseURL.replace(/^http/, "ws") + routePath, ["sandbox.bearer", patToken]);
2617
+ let startResolve;
2618
+ let startReject;
2619
+ const started = new Promise((resolve, reject) => {
2620
+ startResolve = resolve;
2621
+ startReject = reject;
2622
+ });
2623
+ let endResolve;
2624
+ let endReject;
2625
+ const ended = new Promise((resolve, reject) => {
2626
+ endResolve = resolve;
2627
+ endReject = reject;
2628
+ });
2629
+ // A caller that never calls wait() must not see an unhandled rejection.
2630
+ ended.catch(() => undefined);
2631
+ let opened = false;
2632
+ let isStarted = false;
2633
+ let startFailed = false;
2634
+ let settled = false;
2635
+ let lastErrorDetail = "";
2636
+ const finish = (err) => {
2637
+ if (settled)
2638
+ return;
2639
+ settled = true;
2640
+ if (err) {
2641
+ endReject(err);
2642
+ }
2643
+ else {
2644
+ endResolve();
2645
+ }
2646
+ };
2647
+ const handle = {
2648
+ stop() {
2649
+ finish();
2650
+ ws.close();
2651
+ },
2652
+ wait() {
2653
+ return ended;
2654
+ },
2655
+ };
2656
+ const fail = (detail) => {
2657
+ clearTimeout(startTimer);
2658
+ if (isStarted) {
2659
+ finish(new Error(`file watch stream closed${detail}`));
2660
+ return;
2661
+ }
2662
+ if (startFailed)
2663
+ return;
2664
+ startFailed = true;
2665
+ if (opened) {
2666
+ startReject(new Error(`file watch closed before it started${detail}`));
2667
+ return;
2668
+ }
2669
+ void refusal(detail).then(startReject, startReject);
2670
+ };
2671
+ // The wait for "start" is bounded: a proxy that accepts the upgrade and
2672
+ // then goes quiet would otherwise leave the caller waiting forever. Later
2673
+ // frames are not bounded, since a quiet folder sends nothing.
2674
+ const startTimer = setTimeout(() => {
2675
+ if (isStarted || startFailed)
2676
+ return;
2677
+ startFailed = true;
2678
+ startReject(new Error(`file watch did not start within ${WATCH_START_TIMEOUT_MS / 1000}s`));
2679
+ ws.close();
2680
+ }, WATCH_START_TIMEOUT_MS);
2681
+ // In Node, the pending wait alone must not keep the process alive.
2682
+ startTimer.unref?.();
2683
+ ws.addEventListener("open", () => {
2684
+ opened = true;
2685
+ });
2686
+ ws.addEventListener("message", (event) => {
2687
+ if (typeof event.data !== "string")
2688
+ return;
2689
+ let msg;
2690
+ try {
2691
+ msg = JSON.parse(event.data);
2692
+ }
2693
+ catch {
2694
+ return;
2695
+ }
2696
+ switch (msg.type) {
2697
+ case "start":
2698
+ clearTimeout(startTimer);
2699
+ if (!isStarted && !startFailed) {
2700
+ isStarted = true;
2701
+ startResolve(handle);
2702
+ }
2703
+ return;
2704
+ case "events":
2705
+ if (!Array.isArray(msg.events))
2706
+ return;
2707
+ for (const raw of msg.events) {
2708
+ // stop() inside the callback ends delivery at once.
2709
+ if (settled)
2710
+ return;
2711
+ try {
2712
+ onEvent(fromApiFileEvent(raw));
2713
+ }
2714
+ catch (err) {
2715
+ // A throwing callback ends the watch; wait() reports why.
2716
+ finish(err instanceof Error ? err : new Error(String(err)));
2717
+ ws.close();
2718
+ return;
2719
+ }
2720
+ }
2721
+ return;
2722
+ case "end":
2723
+ finish(new Error(typeof msg.reason === "string" && msg.reason !== "" ? msg.reason : "file watch ended"));
2724
+ ws.close();
2725
+ return;
2726
+ }
2727
+ });
2728
+ ws.addEventListener("close", (event) => {
2729
+ const parts = [lastErrorDetail, describeWSClose(event)].filter((s) => s !== "");
2730
+ fail(parts.length > 0 ? ` (${parts.join("; ")})` : "");
2731
+ });
2732
+ ws.addEventListener("error", (event) => {
2733
+ const detail = describeWSError(event);
2734
+ if (detail !== "") {
2735
+ lastErrorDetail = detail;
2736
+ }
2737
+ fail(detail !== "" ? ` (${detail})` : "");
2738
+ });
2739
+ return started;
2740
+ }