@aerol-ai/aerolvm-sdk 0.8.0 → 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.
- package/dist/MicroVM.d.ts +133 -51
- package/dist/MicroVM.js +207 -99
- package/dist/errors.d.ts +52 -0
- package/dist/errors.js +89 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/internal/client.d.ts +223 -34
- package/dist/internal/client.js +1116 -85
- package/dist/internal/tar.d.ts +14 -0
- package/dist/internal/tar.js +79 -0
- package/dist/types.d.ts +458 -2
- package/package.json +1 -1
package/dist/internal/client.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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}`);
|
|
@@ -405,8 +572,12 @@ export class APIClient {
|
|
|
405
572
|
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/stop`);
|
|
406
573
|
return this.wrap(response);
|
|
407
574
|
}
|
|
408
|
-
async createSnapshot(id, name) {
|
|
409
|
-
const
|
|
575
|
+
async createSnapshot(id, name, options = {}) {
|
|
576
|
+
const body = { name };
|
|
577
|
+
if (options.allowPause !== undefined) {
|
|
578
|
+
body.allow_pause = options.allowPause;
|
|
579
|
+
}
|
|
580
|
+
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/snapshot`, body);
|
|
410
581
|
return fromApiSandboxSnapshot(response);
|
|
411
582
|
}
|
|
412
583
|
async registerSnapshot(options) {
|
|
@@ -460,6 +631,32 @@ export class APIClient {
|
|
|
460
631
|
async destroy(id) {
|
|
461
632
|
await this.doJSON("DELETE", `${this.versionPrefix}/sandboxes/${id}`);
|
|
462
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
|
+
}
|
|
463
660
|
async resize(id, options) {
|
|
464
661
|
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/resize`, toApiResizeOptions(options));
|
|
465
662
|
return this.wrap(response);
|
|
@@ -468,13 +665,105 @@ export class APIClient {
|
|
|
468
665
|
const response = await this.doJSON("PUT", `${this.versionPrefix}/sandboxes/${id}/lifecycle`, toApiLifecycle(lifecycle));
|
|
469
666
|
return this.wrap(response);
|
|
470
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
|
+
}
|
|
471
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);
|
|
472
703
|
// Not idempotent: a re-sent exec runs the caller's command a second time.
|
|
473
704
|
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/process/execute`, toApiExecRequest(request), { idempotent: false });
|
|
474
705
|
return fromApiExecResult(response);
|
|
475
706
|
}
|
|
476
707
|
execStream(id, options) {
|
|
477
|
-
|
|
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 }));
|
|
478
767
|
}
|
|
479
768
|
async createSession(id, options) {
|
|
480
769
|
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/sessions`, toApiCreateSessionOptions(options));
|
|
@@ -506,21 +795,256 @@ export class APIClient {
|
|
|
506
795
|
attachSession(id, sessionID, options = {}) {
|
|
507
796
|
return openSessionAttach(this.baseURL, this.versionPrefix, this.patToken, id, sessionID, options);
|
|
508
797
|
}
|
|
509
|
-
|
|
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);
|
|
510
805
|
const form = new FormData();
|
|
511
806
|
form.set("path", targetPath);
|
|
512
807
|
form.set("file", toBlob(data), basename(targetPath));
|
|
513
|
-
const response = await this.request("POST",
|
|
808
|
+
const response = await this.request("POST", path, { body: form }, { idempotent: !(options.append || options.createOnly) });
|
|
514
809
|
if (!response.ok) {
|
|
515
810
|
throw await decodeError(response);
|
|
516
811
|
}
|
|
517
812
|
}
|
|
518
|
-
|
|
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 = {}) {
|
|
519
913
|
const response = await this.request("GET", `${this.versionPrefix}/sandboxes/${id}/toolbox/files/download?path=${encodeURIComponent(targetPath)}`);
|
|
520
914
|
if (!response.ok) {
|
|
521
915
|
throw await decodeError(response);
|
|
522
916
|
}
|
|
523
|
-
|
|
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
|
+
}));
|
|
524
1048
|
}
|
|
525
1049
|
/**
|
|
526
1050
|
* Publish a sandbox container port. The chosen wire protocol is selected by
|
|
@@ -536,13 +1060,60 @@ export class APIClient {
|
|
|
536
1060
|
* variant carries `host` / `hostPort` — everything else is in `url`.
|
|
537
1061
|
*/
|
|
538
1062
|
async exposePort(id, port, options = {}) {
|
|
539
|
-
|
|
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;
|
|
540
1073
|
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/ports/${port}`, body);
|
|
541
1074
|
return fromApiExposePortResponse(response);
|
|
542
1075
|
}
|
|
543
1076
|
async unexposePort(id, port) {
|
|
544
1077
|
await this.doJSON("DELETE", `${this.versionPrefix}/sandboxes/${id}/ports/${port}`);
|
|
545
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
|
+
}
|
|
546
1117
|
/**
|
|
547
1118
|
* Bind a custom hostname to a sandbox. The server lowercases the hostname
|
|
548
1119
|
* and returns the post-add list of bindings (sorted by hostname). Calling
|
|
@@ -624,6 +1195,8 @@ export class APIClient {
|
|
|
624
1195
|
params.set("cursor", options.cursor);
|
|
625
1196
|
if (options.incarnationID)
|
|
626
1197
|
params.set("incarnation_id", options.incarnationID);
|
|
1198
|
+
if (options.caller)
|
|
1199
|
+
params.set("caller", options.caller);
|
|
627
1200
|
const query = params.toString();
|
|
628
1201
|
const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/audit${query ? `?${query}` : ""}`);
|
|
629
1202
|
return fromApiAuditPage(response);
|
|
@@ -694,6 +1267,68 @@ export class APIClient {
|
|
|
694
1267
|
async deleteEgressProfile(name) {
|
|
695
1268
|
await this.doJSON("DELETE", this.versioned(`/egress-profiles/${encodeURIComponent(name)}`));
|
|
696
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
|
+
}
|
|
697
1332
|
async createTemplate(options) {
|
|
698
1333
|
const response = await this.doJSON("POST", this.versioned("/templates"), {
|
|
699
1334
|
id: options.id,
|
|
@@ -781,7 +1416,7 @@ export class APIClient {
|
|
|
781
1416
|
await this.doJSON("DELETE", this.versioned(`/tokens/${encodeURIComponent(trimmed)}`));
|
|
782
1417
|
}
|
|
783
1418
|
wrap(sandbox) {
|
|
784
|
-
return new SandboxResource(this, fromApiSandbox(sandbox));
|
|
1419
|
+
return new SandboxResource(this.root ?? this, fromApiSandbox(sandbox));
|
|
785
1420
|
}
|
|
786
1421
|
async doJSON(method, path, body, options) {
|
|
787
1422
|
const init = { headers: { ...options?.headers } };
|
|
@@ -824,25 +1459,37 @@ export class APIClient {
|
|
|
824
1459
|
* {@link retryDelayMs}). The client's `signal` ends the request and any
|
|
825
1460
|
* wait.
|
|
826
1461
|
*/
|
|
827
|
-
|
|
828
|
-
const headers = new Headers(
|
|
1462
|
+
authHeaders(base) {
|
|
1463
|
+
const headers = new Headers(base);
|
|
829
1464
|
if (this.patToken !== "") {
|
|
830
1465
|
headers.set("Authorization", `Bearer ${this.patToken}`);
|
|
831
1466
|
}
|
|
832
1467
|
if (this.team !== "") {
|
|
833
1468
|
headers.set(TEAM_HEADER, this.team);
|
|
834
1469
|
}
|
|
1470
|
+
return headers;
|
|
1471
|
+
}
|
|
1472
|
+
async request(method, path, init = {}, options = {}) {
|
|
1473
|
+
const headers = this.authHeaders(init.headers);
|
|
835
1474
|
const url = `${this.baseURL}${path}`;
|
|
836
1475
|
const signal = init.signal ?? this.signal;
|
|
837
1476
|
const requestInit = { ...init, method, headers, ...(signal ? { signal } : {}) };
|
|
838
|
-
const
|
|
1477
|
+
const maxRetries = options.sendOnce ? 0 : this.retryConfig.maxRetries;
|
|
839
1478
|
const idempotent = options.idempotent ?? true;
|
|
840
1479
|
const retryableStatusCodes = idempotent ? RETRYABLE_STATUS_CODES : REFUSED_STATUS_CODES;
|
|
841
1480
|
const isRetryableError = idempotent ? isTransientTransportError : isUnsentTransportError;
|
|
1481
|
+
let mayHaveRun = false;
|
|
842
1482
|
for (let attempt = 0;; attempt++) {
|
|
843
1483
|
let retryAfter = null;
|
|
844
1484
|
try {
|
|
845
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
|
+
}
|
|
846
1493
|
if (!retryableStatusCodes.has(response.status) || attempt >= maxRetries) {
|
|
847
1494
|
return response;
|
|
848
1495
|
}
|
|
@@ -858,24 +1505,56 @@ export class APIClient {
|
|
|
858
1505
|
if (signal?.aborted || !isRetryableError(error) || attempt >= maxRetries) {
|
|
859
1506
|
throw error;
|
|
860
1507
|
}
|
|
1508
|
+
mayHaveRun ||= !isUnsentTransportError(error);
|
|
861
1509
|
}
|
|
862
1510
|
await sleep(retryDelayMs(this.retryConfig, attempt, retryAfter, Date.now(), Math.random()), signal);
|
|
863
1511
|
}
|
|
864
1512
|
}
|
|
865
1513
|
}
|
|
866
1514
|
export class SandboxResource {
|
|
867
|
-
|
|
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
|
+
}
|
|
868
1527
|
constructor(client, sandbox) {
|
|
869
|
-
|
|
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 });
|
|
870
1531
|
this.apply(sandbox);
|
|
871
1532
|
}
|
|
872
|
-
|
|
873
|
-
|
|
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);
|
|
874
1553
|
this.apply(updated.toJSON());
|
|
875
1554
|
return this;
|
|
876
1555
|
}
|
|
877
|
-
async exec(command) {
|
|
878
|
-
return this.
|
|
1556
|
+
async exec(command, call) {
|
|
1557
|
+
return this.api(call).exec(this.id, typeof command === "string" ? { command } : command);
|
|
879
1558
|
}
|
|
880
1559
|
/**
|
|
881
1560
|
* Read this sandbox's clone-generation token. The token changes whenever the
|
|
@@ -883,50 +1562,122 @@ export class SandboxResource {
|
|
|
883
1562
|
* clone (e.g. to re-run setup). It does NOT reseed in-guest PRNGs — see the
|
|
884
1563
|
* "Randomness in cloned sandboxes" docs page for the in-guest pattern.
|
|
885
1564
|
*/
|
|
886
|
-
async cloneGeneration() {
|
|
887
|
-
return this.
|
|
1565
|
+
async cloneGeneration(call) {
|
|
1566
|
+
return this.api(call).cloneGeneration(this.id);
|
|
888
1567
|
}
|
|
889
1568
|
execStream(options) {
|
|
890
1569
|
return this.client.execStream(this.id, options);
|
|
891
1570
|
}
|
|
892
|
-
|
|
893
|
-
|
|
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);
|
|
894
1577
|
}
|
|
895
|
-
async
|
|
896
|
-
return this.
|
|
1578
|
+
async createSession(options, call) {
|
|
1579
|
+
return this.api(call).createSession(this.id, options);
|
|
897
1580
|
}
|
|
898
|
-
async
|
|
899
|
-
return this.
|
|
1581
|
+
async listSessions(call) {
|
|
1582
|
+
return this.api(call).listSessions(this.id);
|
|
900
1583
|
}
|
|
901
|
-
async
|
|
902
|
-
|
|
1584
|
+
async getSession(sessionID, call) {
|
|
1585
|
+
return this.api(call).getSession(this.id, sessionID);
|
|
903
1586
|
}
|
|
904
|
-
async
|
|
905
|
-
await this.
|
|
1587
|
+
async deleteSession(sessionID, call) {
|
|
1588
|
+
await this.api(call).deleteSession(this.id, sessionID);
|
|
906
1589
|
}
|
|
907
|
-
async
|
|
908
|
-
await this.
|
|
1590
|
+
async signalSession(sessionID, signal, call) {
|
|
1591
|
+
await this.api(call).signalSession(this.id, sessionID, signal);
|
|
909
1592
|
}
|
|
910
|
-
async
|
|
911
|
-
|
|
1593
|
+
async resizeSession(sessionID, cols, rows, call) {
|
|
1594
|
+
await this.api(call).resizeSession(this.id, sessionID, cols, rows);
|
|
912
1595
|
}
|
|
913
|
-
async
|
|
914
|
-
return this.
|
|
1596
|
+
async sessionLog(sessionID, call) {
|
|
1597
|
+
return this.api(call).sessionLog(this.id, sessionID);
|
|
1598
|
+
}
|
|
1599
|
+
async sessionRecording(sessionID, call) {
|
|
1600
|
+
return this.api(call).sessionRecording(this.id, sessionID);
|
|
915
1601
|
}
|
|
916
1602
|
attachSession(sessionID, options = {}) {
|
|
917
1603
|
return this.client.attachSession(this.id, sessionID, options);
|
|
918
1604
|
}
|
|
919
|
-
|
|
920
|
-
|
|
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);
|
|
921
1631
|
}
|
|
922
|
-
|
|
923
|
-
|
|
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);
|
|
924
1635
|
}
|
|
925
|
-
|
|
926
|
-
|
|
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);
|
|
927
1639
|
}
|
|
928
|
-
|
|
929
|
-
|
|
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);
|
|
1643
|
+
}
|
|
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);
|
|
930
1681
|
}
|
|
931
1682
|
/**
|
|
932
1683
|
* Per-sandbox custom-hostname bindings. Exposed as a namespaced accessor
|
|
@@ -940,51 +1691,51 @@ export class SandboxResource {
|
|
|
940
1691
|
const client = this.client;
|
|
941
1692
|
const id = this.id;
|
|
942
1693
|
return {
|
|
943
|
-
add: (hostname, options) => client.addCustomDomain(id, hostname, options),
|
|
944
|
-
remove: (hostname) => client.removeCustomDomain(id, hostname),
|
|
945
|
-
list: () => client.listCustomDomains(id),
|
|
946
|
-
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),
|
|
947
1698
|
};
|
|
948
1699
|
}
|
|
949
|
-
async createSnapshot(name) {
|
|
950
|
-
return this.
|
|
1700
|
+
async createSnapshot(name, options = {}, call) {
|
|
1701
|
+
return this.api(call).createSnapshot(this.id, name, options);
|
|
951
1702
|
}
|
|
952
|
-
async start() {
|
|
953
|
-
const updated = await this.
|
|
1703
|
+
async start(call) {
|
|
1704
|
+
const updated = await this.api(call).start(this.id);
|
|
954
1705
|
this.apply(updated.toJSON());
|
|
955
1706
|
return this;
|
|
956
1707
|
}
|
|
957
|
-
async stop() {
|
|
958
|
-
const updated = await this.
|
|
1708
|
+
async stop(call) {
|
|
1709
|
+
const updated = await this.api(call).stop(this.id);
|
|
959
1710
|
this.apply(updated.toJSON());
|
|
960
1711
|
return this;
|
|
961
1712
|
}
|
|
962
|
-
async destroy() {
|
|
963
|
-
await this.
|
|
1713
|
+
async destroy(call) {
|
|
1714
|
+
await this.api(call).destroy(this.id);
|
|
964
1715
|
}
|
|
965
|
-
async resize(options) {
|
|
966
|
-
const updated = await this.
|
|
1716
|
+
async resize(options, call) {
|
|
1717
|
+
const updated = await this.api(call).resize(this.id, options);
|
|
967
1718
|
this.apply(updated.toJSON());
|
|
968
1719
|
return this;
|
|
969
1720
|
}
|
|
970
|
-
async updateLifecycle(lifecycle) {
|
|
971
|
-
const updated = await this.
|
|
1721
|
+
async updateLifecycle(lifecycle, call) {
|
|
1722
|
+
const updated = await this.api(call).updateLifecycle(this.id, lifecycle);
|
|
972
1723
|
this.apply(updated.toJSON());
|
|
973
1724
|
return this;
|
|
974
1725
|
}
|
|
975
|
-
async getNetworkUsage() {
|
|
976
|
-
return this.
|
|
1726
|
+
async getNetworkUsage(call) {
|
|
1727
|
+
return this.api(call).getNetworkUsage(this.id);
|
|
977
1728
|
}
|
|
978
|
-
async setNetworkLimits(options) {
|
|
979
|
-
return this.
|
|
1729
|
+
async setNetworkLimits(options, call) {
|
|
1730
|
+
return this.api(call).setNetworkLimits(this.id, options);
|
|
980
1731
|
}
|
|
981
1732
|
/**
|
|
982
1733
|
* Replaces this sandbox's egress policy while it runs. The call returns
|
|
983
1734
|
* once the new policy is enforced; sending the same policy again is a
|
|
984
1735
|
* no-op, so it is safe to retry.
|
|
985
1736
|
*/
|
|
986
|
-
async setNetworkPolicy(options) {
|
|
987
|
-
const policy = await this.
|
|
1737
|
+
async setNetworkPolicy(options, call) {
|
|
1738
|
+
const policy = await this.api(call).setNetworkPolicy(this.id, options);
|
|
988
1739
|
this.networkBlockAll = policy.networkBlockAll;
|
|
989
1740
|
this.egressStatus = policy.egressStatus;
|
|
990
1741
|
this.egressProfiles = policy.egressProfiles.length ? policy.egressProfiles : undefined;
|
|
@@ -997,19 +1748,21 @@ export class SandboxResource {
|
|
|
997
1748
|
* would have allowed it. A recording stays readable after the sandbox
|
|
998
1749
|
* switches to enforce, until it is destroyed.
|
|
999
1750
|
*/
|
|
1000
|
-
async learned() {
|
|
1001
|
-
return this.
|
|
1751
|
+
async learned(call) {
|
|
1752
|
+
return this.api(call).getNetworkLearned(this.id);
|
|
1002
1753
|
}
|
|
1003
1754
|
/**
|
|
1004
1755
|
* Reads this sandbox's audit log: outbound connections and egress denials
|
|
1005
1756
|
* (`kind: "egress"`), and secret reads. Denials carry `result: "failure"`
|
|
1006
1757
|
* and the policy `reason`.
|
|
1007
1758
|
*/
|
|
1008
|
-
async audit(options) {
|
|
1009
|
-
return this.
|
|
1759
|
+
async audit(options, call) {
|
|
1760
|
+
return this.api(call).getAudit(this.id, options);
|
|
1010
1761
|
}
|
|
1011
1762
|
toJSON() {
|
|
1012
|
-
|
|
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);
|
|
1013
1766
|
}
|
|
1014
1767
|
apply(sandbox) {
|
|
1015
1768
|
Object.assign(this, cloneSandbox(sandbox));
|
|
@@ -1041,6 +1794,7 @@ function toApiCreateOptions(options) {
|
|
|
1041
1794
|
platform_volumes: options.platformVolumes?.map(toApiPlatformVolumeMount),
|
|
1042
1795
|
lifecycle: options.lifecycle ? toApiLifecycle(options.lifecycle) : undefined,
|
|
1043
1796
|
failover: options.failover ? toApiFailover(options.failover) : undefined,
|
|
1797
|
+
placement: options.placement ? { zone: options.placement.zone, labels: options.placement.labels } : undefined,
|
|
1044
1798
|
runtime: options.runtime,
|
|
1045
1799
|
durability: options.durability,
|
|
1046
1800
|
module_ref: options.moduleRef,
|
|
@@ -1048,6 +1802,7 @@ function toApiCreateOptions(options) {
|
|
|
1048
1802
|
custom_domains: options.customDomains,
|
|
1049
1803
|
gpus: options.gpus ? toApiGPUOptions(options.gpus) : undefined,
|
|
1050
1804
|
template_id: options.templateId,
|
|
1805
|
+
identity: options.identity ? toApiIdentity(options.identity) : undefined,
|
|
1051
1806
|
};
|
|
1052
1807
|
}
|
|
1053
1808
|
function toApiGPUOptions(gpus) {
|
|
@@ -1081,9 +1836,12 @@ function toApiResizeOptions(options) {
|
|
|
1081
1836
|
function toApiExecRequest(request) {
|
|
1082
1837
|
return {
|
|
1083
1838
|
command: request.command,
|
|
1839
|
+
argv: request.argv,
|
|
1084
1840
|
workdir: request.workDir,
|
|
1085
1841
|
env: request.env,
|
|
1086
1842
|
timeout_seconds: request.timeoutSeconds,
|
|
1843
|
+
max_stdout_bytes: request.maxStdoutBytes,
|
|
1844
|
+
max_stderr_bytes: request.maxStderrBytes,
|
|
1087
1845
|
};
|
|
1088
1846
|
}
|
|
1089
1847
|
function toApiCreateSessionOptions(options) {
|
|
@@ -1105,6 +1863,9 @@ function toApiLifecycle(lifecycle) {
|
|
|
1105
1863
|
stop_at_age: lifecycle.stopAtAge,
|
|
1106
1864
|
destroy_at_age: lifecycle.destroyAtAge,
|
|
1107
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,
|
|
1108
1869
|
};
|
|
1109
1870
|
}
|
|
1110
1871
|
function toApiFailover(failover) {
|
|
@@ -1140,6 +1901,9 @@ function fromApiSandbox(sandbox) {
|
|
|
1140
1901
|
updatedAt: sandbox.updated_at,
|
|
1141
1902
|
lastActiveAt: sandbox.last_active_at,
|
|
1142
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,
|
|
1143
1907
|
containerCommand: sandbox.container_command,
|
|
1144
1908
|
lifecycle: fromApiLifecycle(sandbox.lifecycle),
|
|
1145
1909
|
failover: fromApiFailover(sandbox.failover),
|
|
@@ -1148,6 +1912,7 @@ function fromApiSandbox(sandbox) {
|
|
|
1148
1912
|
moduleRef: sandbox.module_ref,
|
|
1149
1913
|
moduleDigest: sandbox.module_digest,
|
|
1150
1914
|
tenantId: sandbox.tenant_id,
|
|
1915
|
+
identity: fromApiSandboxIdentity(sandbox.identity),
|
|
1151
1916
|
};
|
|
1152
1917
|
}
|
|
1153
1918
|
// normalizeRuntime narrows the wire-level string to the union we expose, while
|
|
@@ -1253,6 +2018,7 @@ function fromApiExposedPort(port) {
|
|
|
1253
2018
|
port: port.port,
|
|
1254
2019
|
publicURL: port.public_url,
|
|
1255
2020
|
createdAt: port.created_at,
|
|
2021
|
+
...(port.visibility === "token" ? { visibility: "token" } : {}),
|
|
1256
2022
|
};
|
|
1257
2023
|
}
|
|
1258
2024
|
function fromApiExposePortResponse(response) {
|
|
@@ -1267,7 +2033,9 @@ function fromApiExposePortResponse(response) {
|
|
|
1267
2033
|
case "tls":
|
|
1268
2034
|
return { protocol: "tls", url: response.public_url };
|
|
1269
2035
|
case "http":
|
|
1270
|
-
return
|
|
2036
|
+
return response.visibility === "token"
|
|
2037
|
+
? { protocol: "http", url: response.public_url, visibility: "token" }
|
|
2038
|
+
: { protocol: "http", url: response.public_url };
|
|
1271
2039
|
}
|
|
1272
2040
|
}
|
|
1273
2041
|
function fromApiExecResult(result) {
|
|
@@ -1276,6 +2044,10 @@ function fromApiExecResult(result) {
|
|
|
1276
2044
|
stderr: result.stderr,
|
|
1277
2045
|
exitCode: result.exit_code,
|
|
1278
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,
|
|
1279
2051
|
};
|
|
1280
2052
|
}
|
|
1281
2053
|
function fromApiHealthStatus(status) {
|
|
@@ -1309,6 +2081,7 @@ function toApiPlatformVolumeMount(volume) {
|
|
|
1309
2081
|
name: volume.name,
|
|
1310
2082
|
path: volume.path,
|
|
1311
2083
|
read_only: volume.readOnly,
|
|
2084
|
+
delete_with_sandbox: volume.deleteWithSandbox,
|
|
1312
2085
|
};
|
|
1313
2086
|
}
|
|
1314
2087
|
function fromApiMountSpecRedacted(mount) {
|
|
@@ -1381,6 +2154,12 @@ function fromApiLifecycle(lifecycle) {
|
|
|
1381
2154
|
if (lifecycle?.serverless) {
|
|
1382
2155
|
result.serverless = true;
|
|
1383
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
|
+
}
|
|
1384
2163
|
return result;
|
|
1385
2164
|
}
|
|
1386
2165
|
function fromApiFailover(failover) {
|
|
@@ -1435,6 +2214,7 @@ function cloneSandbox(sandbox) {
|
|
|
1435
2214
|
containerCommand: sandbox.containerCommand ? [...sandbox.containerCommand] : undefined,
|
|
1436
2215
|
lifecycle: { ...sandbox.lifecycle },
|
|
1437
2216
|
failover: sandbox.failover ? { ...sandbox.failover } : undefined,
|
|
2217
|
+
identity: sandbox.identity ? { ...sandbox.identity } : undefined,
|
|
1438
2218
|
};
|
|
1439
2219
|
}
|
|
1440
2220
|
function toBlob(data) {
|
|
@@ -1449,23 +2229,91 @@ function toBlob(data) {
|
|
|
1449
2229
|
}
|
|
1450
2230
|
return new Blob([Uint8Array.from(data)]);
|
|
1451
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
|
+
}
|
|
1452
2296
|
async function decodeError(response) {
|
|
1453
2297
|
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"), Date.now());
|
|
1454
2298
|
try {
|
|
1455
2299
|
const payload = (await response.json());
|
|
1456
2300
|
if (typeof payload.error === "string" && payload.error !== "") {
|
|
1457
2301
|
const code = typeof payload.code === "string" && payload.code !== "" ? payload.code : undefined;
|
|
1458
|
-
return
|
|
2302
|
+
return apiErrorFor(response.status, payload.error, { code, retryAfterMs });
|
|
1459
2303
|
}
|
|
1460
2304
|
}
|
|
1461
2305
|
catch {
|
|
1462
2306
|
// Not JSON (a proxy's error page) or no body: fall through to the
|
|
1463
2307
|
// status-based message, with no code.
|
|
1464
2308
|
}
|
|
1465
|
-
return
|
|
2309
|
+
return apiErrorFor(response.status, `request failed with status ${response.status}`, { retryAfterMs });
|
|
1466
2310
|
}
|
|
1467
2311
|
const STREAM_PREFIX_STDOUT = 0x01;
|
|
1468
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";
|
|
1469
2317
|
// Pull whatever diagnostic detail the runtime gave us off a WebSocket "error"
|
|
1470
2318
|
// event. Node 22's native WebSocket fires an ErrorEvent with `.error` /
|
|
1471
2319
|
// `.message`; browsers' base WebSocket fires a bare Event. Returns "" when
|
|
@@ -1501,7 +2349,7 @@ function toWebSocketBinaryFrame(data) {
|
|
|
1501
2349
|
frame.set(bytes);
|
|
1502
2350
|
return frame;
|
|
1503
2351
|
}
|
|
1504
|
-
function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options) {
|
|
2352
|
+
function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options, ready) {
|
|
1505
2353
|
const wsURL = baseURL.replace(/^http/, "ws") + `${versionPrefix}/sandboxes/${encodeURIComponent(sandboxID)}/toolbox/process/exec/stream`;
|
|
1506
2354
|
const WS = globalThis.WebSocket;
|
|
1507
2355
|
if (!WS) {
|
|
@@ -1533,17 +2381,51 @@ function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options) {
|
|
|
1533
2381
|
if (resolved)
|
|
1534
2382
|
return;
|
|
1535
2383
|
resolved = true;
|
|
1536
|
-
exitReject?.(new Error(msg));
|
|
2384
|
+
exitReject?.(typeof msg === "string" ? new Error(msg) : msg);
|
|
1537
2385
|
};
|
|
1538
|
-
|
|
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 = () => {
|
|
1539
2399
|
ws.send(JSON.stringify({
|
|
1540
2400
|
command: options.command,
|
|
2401
|
+
argv: options.argv,
|
|
1541
2402
|
workdir: options.workdir,
|
|
1542
2403
|
env: options.env,
|
|
1543
2404
|
tty: options.tty ?? false,
|
|
1544
2405
|
cols: options.cols ?? 0,
|
|
1545
2406
|
rows: options.rows ?? 0,
|
|
1546
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
|
+
});
|
|
1547
2429
|
});
|
|
1548
2430
|
ws.addEventListener("message", (event) => {
|
|
1549
2431
|
if (typeof event.data === "string") {
|
|
@@ -1591,18 +2473,18 @@ function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options) {
|
|
|
1591
2473
|
failWith(detail !== "" ? `websocket error: ${detail}` : "websocket error");
|
|
1592
2474
|
});
|
|
1593
2475
|
const sendBinary = (data) => {
|
|
1594
|
-
|
|
2476
|
+
send(toWebSocketBinaryFrame(data));
|
|
1595
2477
|
};
|
|
1596
2478
|
return {
|
|
1597
2479
|
write: sendBinary,
|
|
1598
2480
|
resize(cols, rows) {
|
|
1599
|
-
|
|
2481
|
+
send(JSON.stringify({ type: "resize", cols, rows }));
|
|
1600
2482
|
},
|
|
1601
2483
|
signal(name) {
|
|
1602
|
-
|
|
2484
|
+
send(JSON.stringify({ type: "signal", signal: name }));
|
|
1603
2485
|
},
|
|
1604
2486
|
close() {
|
|
1605
|
-
|
|
2487
|
+
send(JSON.stringify({ type: "close" }));
|
|
1606
2488
|
},
|
|
1607
2489
|
done,
|
|
1608
2490
|
};
|
|
@@ -1707,3 +2589,152 @@ function openSessionAttach(baseURL, versionPrefix, patToken, sandboxID, sessionI
|
|
|
1707
2589
|
done,
|
|
1708
2590
|
};
|
|
1709
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
|
+
}
|