@aerol-ai/aerolvm-sdk 0.8.1 → 0.8.3
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 +253 -33
- package/dist/internal/client.js +1249 -91
- package/dist/internal/tar.d.ts +14 -0
- package/dist/internal/tar.js +79 -0
- package/dist/types.d.ts +448 -2
- package/package.json +1 -1
package/dist/internal/client.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
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
|
|
7
10
|
* acts for. Keep in sync with pkg/api.HeaderTeam on the server.
|
|
8
11
|
*/
|
|
9
12
|
export const TEAM_HEADER = "X-AerolVM-Team";
|
|
13
|
+
/**
|
|
14
|
+
* The response header a 421 carries when the sandbox lives in another cell
|
|
15
|
+
* (a separate AerolVM install in the same federation): that cell's base URL.
|
|
16
|
+
* Keep in sync with models.CellEndpointHeader on the server.
|
|
17
|
+
*/
|
|
18
|
+
export const CELL_ENDPOINT_HEADER = "X-Aerolvm-Cell-Endpoint";
|
|
10
19
|
const DEFAULT_API_VERSION = "v1";
|
|
11
20
|
const PATH_PREFIXES = {
|
|
12
21
|
v1: V1_PATH_PREFIX,
|
|
@@ -36,6 +45,18 @@ function fromApiAPIToken(t) {
|
|
|
36
45
|
expired: t.expired === true,
|
|
37
46
|
};
|
|
38
47
|
}
|
|
48
|
+
function fromApiTeamQuota(q) {
|
|
49
|
+
const out = { team: q.team, generation: q.generation, createdAt: q.created_at, updatedAt: q.updated_at };
|
|
50
|
+
if (q.max_sandboxes)
|
|
51
|
+
out.maxSandboxes = q.max_sandboxes;
|
|
52
|
+
if (q.max_cpu)
|
|
53
|
+
out.maxCPU = q.max_cpu;
|
|
54
|
+
if (q.max_memory_mb)
|
|
55
|
+
out.maxMemoryMB = q.max_memory_mb;
|
|
56
|
+
if (q.usage)
|
|
57
|
+
out.usage = { sandboxes: q.usage.sandboxes, cpu: q.usage.cpu, memoryMB: q.usage.memory_mb };
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
39
60
|
function fromApiEgressProfile(p) {
|
|
40
61
|
const out = {
|
|
41
62
|
name: p.name,
|
|
@@ -48,12 +69,74 @@ function fromApiEgressProfile(p) {
|
|
|
48
69
|
out.description = p.description;
|
|
49
70
|
return out;
|
|
50
71
|
}
|
|
72
|
+
function fromApiWarmPool(p) {
|
|
73
|
+
const out = {
|
|
74
|
+
image: p.image,
|
|
75
|
+
depth: p.depth,
|
|
76
|
+
onImageChange: (p.on_image_change || "replace_now"),
|
|
77
|
+
generation: p.generation,
|
|
78
|
+
createdAt: p.created_at,
|
|
79
|
+
updatedAt: p.updated_at,
|
|
80
|
+
};
|
|
81
|
+
if (p.min_depth)
|
|
82
|
+
out.minDepth = p.min_depth;
|
|
83
|
+
if (p.max_depth)
|
|
84
|
+
out.maxDepth = p.max_depth;
|
|
85
|
+
if (p.status) {
|
|
86
|
+
const status = { ready: p.status.ready, target: p.status.target, outdated: p.status.outdated ?? 0, servers: [] };
|
|
87
|
+
for (const s of p.status.servers ?? []) {
|
|
88
|
+
const server = { nodeId: s.node_id, ready: s.ready, target: s.target, outdated: s.outdated ?? 0 };
|
|
89
|
+
if (s.stale)
|
|
90
|
+
server.stale = true;
|
|
91
|
+
status.servers.push(server);
|
|
92
|
+
}
|
|
93
|
+
out.status = status;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
// An image is the rest of the warm-pool path. Each segment is escaped and the
|
|
98
|
+
// slashes are kept, so ghcr.io/acme/agent:1 reaches the server as written and
|
|
99
|
+
// no proxy has an encoded slash to rewrite.
|
|
100
|
+
function warmPoolPath(image) {
|
|
101
|
+
return `/warm-pools/${image.split("/").map(encodeURIComponent).join("/")}`;
|
|
102
|
+
}
|
|
51
103
|
function toApiEgressRule({ inject, ...rule }) {
|
|
52
104
|
return inject ? { ...rule, inject: { header: inject.header, secret_ref: inject.secretRef } } : rule;
|
|
53
105
|
}
|
|
54
106
|
function fromApiEgressRule({ inject, ...rule }) {
|
|
55
107
|
return inject ? { ...rule, inject: { header: inject.header, secretRef: inject.secret_ref } } : rule;
|
|
56
108
|
}
|
|
109
|
+
function fromApiIdentityRole(r) {
|
|
110
|
+
const out = { tag: r.tag, minRole: r.min_role, createdAt: r.created_at, updatedAt: r.updated_at };
|
|
111
|
+
if (r.team)
|
|
112
|
+
out.team = r.team;
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
function fromApiSandboxIdentity(i) {
|
|
116
|
+
if (!i)
|
|
117
|
+
return undefined;
|
|
118
|
+
const out = { roleTag: i.role_tag, audience: i.audience, subject: i.subject, tokenFile: i.token_file };
|
|
119
|
+
if (i.aws_role_arn)
|
|
120
|
+
out.awsRoleArn = i.aws_role_arn;
|
|
121
|
+
if (i.azure_client_id)
|
|
122
|
+
out.azureClientId = i.azure_client_id;
|
|
123
|
+
if (i.azure_tenant_id)
|
|
124
|
+
out.azureTenantId = i.azure_tenant_id;
|
|
125
|
+
if (i.token_expires_at)
|
|
126
|
+
out.tokenExpiresAt = i.token_expires_at;
|
|
127
|
+
if (i.last_error)
|
|
128
|
+
out.lastError = i.last_error;
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
function toApiIdentity(i) {
|
|
132
|
+
return {
|
|
133
|
+
role_tag: i.roleTag,
|
|
134
|
+
audience: i.audience,
|
|
135
|
+
aws_role_arn: i.awsRoleArn,
|
|
136
|
+
azure_client_id: i.azureClientId,
|
|
137
|
+
azure_tenant_id: i.azureTenantId,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
57
140
|
/** HTTP status codes the retry loop considers transient. */
|
|
58
141
|
// 421 Misdirected Request: an owner answered for a sandbox it doesn't hold
|
|
59
142
|
// (HTTP/2 connection coalescing, or a stale ingress route after failover).
|
|
@@ -68,6 +151,65 @@ const RETRYABLE_STATUS_CODES = new Set([421, 429, 502, 503, 504]);
|
|
|
68
151
|
// over its share, 503 when the node sheds load. A Retry-After on a 421, 502 or
|
|
69
152
|
// 504 is a hop's, not the server's, so those use the normal backoff.
|
|
70
153
|
const REFUSED_STATUS_CODES = new Set([429, 503]);
|
|
154
|
+
// Bounds the sandbox-to-cell map, so a long-lived client that touches many
|
|
155
|
+
// sandboxes in other cells cannot grow without end. A dropped entry costs
|
|
156
|
+
// only one more 421 the next time.
|
|
157
|
+
const MAX_REMEMBERED_CELLS = 10_000;
|
|
158
|
+
/**
|
|
159
|
+
* A 421 that names a cell is the cell router's answer, which sending to the
|
|
160
|
+
* same place again cannot change. The retry loop hands it back at once and
|
|
161
|
+
* {@link APIClient.request} decides whether to follow it.
|
|
162
|
+
*/
|
|
163
|
+
function isCellAnswer(response) {
|
|
164
|
+
return response.status === 421 && (response.headers.get(CELL_ENDPOINT_HEADER) ?? "").trim() !== "";
|
|
165
|
+
}
|
|
166
|
+
// fetch reads a stream while it sends it, so only a body held whole in
|
|
167
|
+
// memory can go out a second time.
|
|
168
|
+
function isReplayableBody(body) {
|
|
169
|
+
return (body == null ||
|
|
170
|
+
typeof body === "string" ||
|
|
171
|
+
body instanceof ArrayBuffer ||
|
|
172
|
+
ArrayBuffer.isView(body) ||
|
|
173
|
+
body instanceof Blob ||
|
|
174
|
+
body instanceof FormData ||
|
|
175
|
+
body instanceof URLSearchParams);
|
|
176
|
+
}
|
|
177
|
+
/** The sandbox ID a per-sandbox path names: the segment right after `prefix` (".../sandboxes/"). */
|
|
178
|
+
function sandboxIDInPath(path, prefix) {
|
|
179
|
+
if (!path.startsWith(prefix))
|
|
180
|
+
return undefined;
|
|
181
|
+
const rest = path.slice(prefix.length);
|
|
182
|
+
const end = rest.search(/[/?#]/);
|
|
183
|
+
const segment = end === -1 ? rest : rest.slice(0, end);
|
|
184
|
+
if (segment === "")
|
|
185
|
+
return undefined;
|
|
186
|
+
// Some call sites escape the ID and some do not; decoding keys both alike.
|
|
187
|
+
try {
|
|
188
|
+
return decodeURIComponent(segment);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return segment;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The toolboxd feature (on /version) behind the file verbs' options: list
|
|
196
|
+
* depth and cap, the move and copy overwrite guard, mkdir without parents and
|
|
197
|
+
* the copy route. An older agent ignores those options (overwrite=false would
|
|
198
|
+
* overwrite), so the calls that send them check first.
|
|
199
|
+
*/
|
|
200
|
+
const FEATURE_FILE_VERBS = "files.verbs";
|
|
201
|
+
/**
|
|
202
|
+
* The toolboxd feature behind upload's write options. An older agent ignores
|
|
203
|
+
* them: createOnly would replace an existing file.
|
|
204
|
+
*/
|
|
205
|
+
const FEATURE_WRITE_OPTIONS = "files.write_options";
|
|
206
|
+
/**
|
|
207
|
+
* The toolboxd feature behind upload's extract=true (uploadDir). An older
|
|
208
|
+
* agent ignores extract and would store the archive itself as a file.
|
|
209
|
+
*/
|
|
210
|
+
const FEATURE_EXTRACT = "files.extract";
|
|
211
|
+
/** The entry cap `listFiles` sends when the caller gives none. */
|
|
212
|
+
export const DEFAULT_LIST_LIMIT = 10_000;
|
|
71
213
|
/**
|
|
72
214
|
* The header a sandbox create carries so the server can recognise a retry of
|
|
73
215
|
* it. Keep in sync with clustercreate.HeaderIdempotencyKey on the server.
|
|
@@ -223,6 +365,21 @@ export function stripTrailingSlashes(value) {
|
|
|
223
365
|
}
|
|
224
366
|
return value.slice(0, end);
|
|
225
367
|
}
|
|
368
|
+
/** A signal that aborts when any of `signals` does, with that one's reason. */
|
|
369
|
+
function anySignal(signals) {
|
|
370
|
+
const native = AbortSignal.any;
|
|
371
|
+
if (typeof native === "function")
|
|
372
|
+
return native.call(AbortSignal, signals);
|
|
373
|
+
const controller = new AbortController();
|
|
374
|
+
for (const signal of signals) {
|
|
375
|
+
if (signal.aborted) {
|
|
376
|
+
controller.abort(signal.reason);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
|
|
380
|
+
}
|
|
381
|
+
return controller.signal;
|
|
382
|
+
}
|
|
226
383
|
/** Waits `ms`, or rejects with the signal's reason as soon as it aborts. */
|
|
227
384
|
function sleep(ms, signal) {
|
|
228
385
|
return new Promise((resolve, reject) => {
|
|
@@ -244,15 +401,30 @@ function sleep(ms, signal) {
|
|
|
244
401
|
export class APIClient {
|
|
245
402
|
baseURL;
|
|
246
403
|
apiVersion;
|
|
247
|
-
patToken;
|
|
248
404
|
team;
|
|
249
405
|
fetchFn;
|
|
250
406
|
versionPrefix;
|
|
251
407
|
retryConfig;
|
|
252
408
|
signal;
|
|
409
|
+
/** The client a {@link withSignal} view was made from; unset on a real client. */
|
|
410
|
+
root;
|
|
411
|
+
/**
|
|
412
|
+
* Features each sandbox's agent listed on /version (requireToolboxFeatures).
|
|
413
|
+
* Refreshed on a miss, because a restart can bring a newer agent.
|
|
414
|
+
*/
|
|
415
|
+
toolboxFeatures = new Map();
|
|
416
|
+
/**
|
|
417
|
+
* The base URL of the cell each sandbox in another cell lives in, learned
|
|
418
|
+
* from a 421 that named it, oldest first. Shared with {@link withSignal}
|
|
419
|
+
* views, so what one call learns routes the next. Entries are dropped only
|
|
420
|
+
* on a 404 from that cell (the sandbox may be gone) or past
|
|
421
|
+
* {@link MAX_REMEMBERED_CELLS}: the cell is minted into the ID, so it
|
|
422
|
+
* never changes.
|
|
423
|
+
*/
|
|
424
|
+
sandboxCells = new Map();
|
|
253
425
|
constructor(config) {
|
|
254
426
|
this.baseURL = stripTrailingSlashes(config.baseURL);
|
|
255
|
-
this
|
|
427
|
+
Object.defineProperty(this, "patToken", { value: config.patToken ?? "", enumerable: false });
|
|
256
428
|
this.team = (config.team ?? "").trim();
|
|
257
429
|
this.fetchFn = config.fetch ?? fetch;
|
|
258
430
|
this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;
|
|
@@ -274,6 +446,22 @@ export class APIClient {
|
|
|
274
446
|
versioned(suffix) {
|
|
275
447
|
return `${this.versionPrefix}${suffix}`;
|
|
276
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* This client with `signal` added to its own: either one aborting ends a
|
|
451
|
+
* request and any retry wait. A per-call signal (plan S6) rides on a view
|
|
452
|
+
* like this rather than a parameter on every internal method. The view
|
|
453
|
+
* shares everything else, the per-sandbox feature cache included, and the
|
|
454
|
+
* sandboxes it returns hold the real client, so the call's signal never
|
|
455
|
+
* outlives the call. Without a signal it is this client.
|
|
456
|
+
*/
|
|
457
|
+
withSignal(signal) {
|
|
458
|
+
if (!signal)
|
|
459
|
+
return this;
|
|
460
|
+
const view = Object.create(this);
|
|
461
|
+
Object.defineProperty(view, "signal", { value: this.signal ? anySignal([this.signal, signal]) : signal });
|
|
462
|
+
Object.defineProperty(view, "root", { value: this.root ?? this });
|
|
463
|
+
return view;
|
|
464
|
+
}
|
|
277
465
|
async create(options) {
|
|
278
466
|
const resolved = await this.resolveImage(options);
|
|
279
467
|
// One key per call, sent unchanged on each of its automatic retries: a
|
|
@@ -392,6 +580,40 @@ export class APIClient {
|
|
|
392
580
|
}
|
|
393
581
|
return this.wrap(items[0]);
|
|
394
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* The caller's sandbox called `name`, created from `options` when there is
|
|
585
|
+
* none; `created` is true only when this call made it. Safe to retry and to
|
|
586
|
+
* run concurrently: names are unique per owner, so a caller that loses a
|
|
587
|
+
* race to create the name gets a 409 and reads the winner's sandbox.
|
|
588
|
+
*/
|
|
589
|
+
async getOrCreate(name, options) {
|
|
590
|
+
const wanted = name.trim();
|
|
591
|
+
if (wanted === "") {
|
|
592
|
+
throw new Error("sandbox name is required");
|
|
593
|
+
}
|
|
594
|
+
const given = options.name?.trim() ?? "";
|
|
595
|
+
if (given !== "" && given !== wanted) {
|
|
596
|
+
throw new Error(`options name "${given}" differs from "${wanted}"`);
|
|
597
|
+
}
|
|
598
|
+
const existing = await this.getByName(wanted);
|
|
599
|
+
if (existing !== null) {
|
|
600
|
+
return { sandbox: existing, created: false };
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
return { sandbox: await this.create({ ...options, name: wanted }), created: true };
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
if (!(err instanceof MicroVMConflictError))
|
|
607
|
+
throw err;
|
|
608
|
+
// Another caller created the name after the lookup. A 409 whose name
|
|
609
|
+
// still cannot be found is a conflict on something else, so the
|
|
610
|
+
// create's own error stands.
|
|
611
|
+
const winner = await this.getByName(wanted);
|
|
612
|
+
if (winner === null)
|
|
613
|
+
throw err;
|
|
614
|
+
return { sandbox: winner, created: false };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
395
617
|
async get(id, options) {
|
|
396
618
|
const q = buildIncludeEnvQuery(options?.includeEnv);
|
|
397
619
|
const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}${q}`);
|
|
@@ -464,6 +686,32 @@ export class APIClient {
|
|
|
464
686
|
async destroy(id) {
|
|
465
687
|
await this.doJSON("DELETE", `${this.versionPrefix}/sandboxes/${id}`);
|
|
466
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Deletes every sandbox carrying all of `tags`, one at a time, after
|
|
691
|
+
* listing every page; returns the IDs now gone. A 404 counts as deleted,
|
|
692
|
+
* so the call is safe to repeat. Any other failure stops it and is thrown.
|
|
693
|
+
*/
|
|
694
|
+
async destroyAll(tags) {
|
|
695
|
+
if (!tags || Object.keys(tags).length === 0) {
|
|
696
|
+
throw new Error("destroyAll needs at least one tag; with none it would delete every sandbox you own");
|
|
697
|
+
}
|
|
698
|
+
const ids = [];
|
|
699
|
+
for await (const page of this.listPages({ tags })) {
|
|
700
|
+
ids.push(...page.map((sandbox) => sandbox.id));
|
|
701
|
+
}
|
|
702
|
+
const gone = [];
|
|
703
|
+
for (const id of ids) {
|
|
704
|
+
try {
|
|
705
|
+
await this.destroy(id);
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
if (!(err instanceof MicroVMNotFoundError))
|
|
709
|
+
throw err;
|
|
710
|
+
}
|
|
711
|
+
gone.push(id);
|
|
712
|
+
}
|
|
713
|
+
return gone;
|
|
714
|
+
}
|
|
467
715
|
async resize(id, options) {
|
|
468
716
|
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/resize`, toApiResizeOptions(options));
|
|
469
717
|
return this.wrap(response);
|
|
@@ -472,13 +720,105 @@ export class APIClient {
|
|
|
472
720
|
const response = await this.doJSON("PUT", `${this.versionPrefix}/sandboxes/${id}/lifecycle`, toApiLifecycle(lifecycle));
|
|
473
721
|
return this.wrap(response);
|
|
474
722
|
}
|
|
723
|
+
/**
|
|
724
|
+
* Fails closed unless the sandbox's agent lists every feature. toolboxd
|
|
725
|
+
* ignores request options it does not know, so sending a newer option to an
|
|
726
|
+
* older agent would silently skip it.
|
|
727
|
+
*/
|
|
728
|
+
async requireToolboxFeatures(id, ...features) {
|
|
729
|
+
if (features.length === 0) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
let have = this.toolboxFeatures.get(id);
|
|
733
|
+
let version = "";
|
|
734
|
+
let runtime = "";
|
|
735
|
+
if (!have || !features.every((feature) => have.has(feature))) {
|
|
736
|
+
const info = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/toolbox/version`);
|
|
737
|
+
have = new Set(Array.isArray(info?.features) ? info.features.map(String) : []);
|
|
738
|
+
this.toolboxFeatures.set(id, have);
|
|
739
|
+
version = typeof info?.version === "string" ? info.version : "";
|
|
740
|
+
runtime = typeof info?.runtime === "string" ? info.runtime : "";
|
|
741
|
+
}
|
|
742
|
+
const missing = features.filter((feature) => !have.has(feature));
|
|
743
|
+
if (missing.length > 0) {
|
|
744
|
+
throw new MicroVMUnsupportedFeatureError(id, missing, version, runtime);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
475
747
|
async exec(id, request) {
|
|
748
|
+
// An older agent ignores the caps and returns all output, and refuses
|
|
749
|
+
// argv with a confusing "command is required"; check first.
|
|
750
|
+
const features = [];
|
|
751
|
+
if (request.maxStdoutBytes || request.maxStderrBytes) {
|
|
752
|
+
features.push("exec.max_output_bytes");
|
|
753
|
+
}
|
|
754
|
+
if (request.argv?.length) {
|
|
755
|
+
features.push("exec.argv");
|
|
756
|
+
}
|
|
757
|
+
await this.requireToolboxFeatures(id, ...features);
|
|
476
758
|
// Not idempotent: a re-sent exec runs the caller's command a second time.
|
|
477
759
|
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/process/execute`, toApiExecRequest(request), { idempotent: false });
|
|
478
760
|
return fromApiExecResult(response);
|
|
479
761
|
}
|
|
480
762
|
execStream(id, options) {
|
|
481
|
-
|
|
763
|
+
if (options.command?.trim() && options.argv?.length) {
|
|
764
|
+
throw new Error("set command or argv, not both");
|
|
765
|
+
}
|
|
766
|
+
// argv waits for the feature check: the start message goes out only once
|
|
767
|
+
// the agent has listed exec.argv, so an older one never receives it.
|
|
768
|
+
const ready = options.argv?.length ? this.requireToolboxFeatures(id, "exec.argv") : undefined;
|
|
769
|
+
return openExecStream(this.baseURLFor(id), this.versionPrefix, this.patToken, id, options, ready);
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Watch a folder in the sandbox for changes. Resolves once the watch is in
|
|
773
|
+
* place, so a change made after it resolves is reported. gVisor and
|
|
774
|
+
* Firecracker sandboxes only: the others answer 501. Not retried: a stream
|
|
775
|
+
* is opened once.
|
|
776
|
+
*/
|
|
777
|
+
async watchFiles(id, path, onEvent, options = {}) {
|
|
778
|
+
const query = `path=${encodeURIComponent(path)}${options.recursive ? "&recursive=true" : ""}`;
|
|
779
|
+
const routePath = `${this.versioned(`/sandboxes/${encodeURIComponent(id)}/toolbox/files/watch`)}?${query}`;
|
|
780
|
+
return openFileWatch(this.baseURLFor(id), routePath, this.patToken, onEvent, (detail) => this.fileWatchRefusal(id, routePath, detail));
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Explains a watch whose WebSocket handshake failed. The WebSocket API
|
|
784
|
+
* hides a refused handshake's status and body, so the same request is sent
|
|
785
|
+
* again as plain HTTP: toolboxd checks the runtime, the path and its limits
|
|
786
|
+
* before the upgrade, so it answers with the real reason (501 on Docker or
|
|
787
|
+
* containerd, 404 for a missing folder, 429 at the watch limit). That GET
|
|
788
|
+
* is a diagnosis, sent once, never a retry of the stream.
|
|
789
|
+
*/
|
|
790
|
+
async fileWatchRefusal(id, routePath, detail) {
|
|
791
|
+
const fallback = new Error(`file watch failed to start${detail}`);
|
|
792
|
+
let response;
|
|
793
|
+
let text;
|
|
794
|
+
try {
|
|
795
|
+
response = await this.fetchFn(`${this.baseURLFor(id)}${routePath}`, {
|
|
796
|
+
method: "GET",
|
|
797
|
+
headers: this.authHeaders(),
|
|
798
|
+
...(this.signal ? { signal: this.signal } : {}),
|
|
799
|
+
});
|
|
800
|
+
text = await response.text();
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
return fallback;
|
|
804
|
+
}
|
|
805
|
+
let message;
|
|
806
|
+
try {
|
|
807
|
+
message = JSON.parse(text).error;
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
// Not JSON: a proxy's error page, or the upgrade's own refusal.
|
|
811
|
+
}
|
|
812
|
+
// An agent that predates file watching has no such route.
|
|
813
|
+
if (response.status === 404 && (response.headers.get(TOOLBOX_ROUTE_HEADER) === "none" || message === "not found")) {
|
|
814
|
+
return new MicroVMUnsupportedFeatureError(id, [FILE_WATCH_FEATURE]);
|
|
815
|
+
}
|
|
816
|
+
// toolboxd accepted the watch and only refused the plain-HTTP upgrade:
|
|
817
|
+
// the stream failed for some other reason, which `detail` holds.
|
|
818
|
+
if (response.ok || response.headers.has("sec-websocket-version")) {
|
|
819
|
+
return fallback;
|
|
820
|
+
}
|
|
821
|
+
return decodeError(new Response(text, { status: response.status, headers: response.headers }));
|
|
482
822
|
}
|
|
483
823
|
async createSession(id, options) {
|
|
484
824
|
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/sessions`, toApiCreateSessionOptions(options));
|
|
@@ -508,23 +848,258 @@ export class APIClient {
|
|
|
508
848
|
return this.doBytes(`${this.versionPrefix}/sandboxes/${id}/sessions/${sessionID}/recording`);
|
|
509
849
|
}
|
|
510
850
|
attachSession(id, sessionID, options = {}) {
|
|
511
|
-
return openSessionAttach(this.
|
|
851
|
+
return openSessionAttach(this.baseURLFor(id), this.versionPrefix, this.patToken, id, sessionID, options);
|
|
512
852
|
}
|
|
513
|
-
|
|
853
|
+
/**
|
|
854
|
+
* Plain and atomic uploads retry on 421/429/502/503/504; `append` and
|
|
855
|
+
* `createOnly` only on 429/503, because a second send would append twice
|
|
856
|
+
* or find the first one's file.
|
|
857
|
+
*/
|
|
858
|
+
async uploadFile(id, targetPath, data, options = {}) {
|
|
859
|
+
const path = await this.uploadPath(id, options);
|
|
514
860
|
const form = new FormData();
|
|
515
861
|
form.set("path", targetPath);
|
|
516
862
|
form.set("file", toBlob(data), basename(targetPath));
|
|
517
|
-
const response = await this.request("POST",
|
|
863
|
+
const response = await this.request("POST", path, { body: form }, { idempotent: !(options.append || options.createOnly) });
|
|
864
|
+
if (!response.ok) {
|
|
865
|
+
throw await decodeError(response);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/** The upload route with the write options as query, after the /version check they need. */
|
|
869
|
+
async uploadPath(id, options) {
|
|
870
|
+
const query = new URLSearchParams();
|
|
871
|
+
if (options.append)
|
|
872
|
+
query.set("append", "true");
|
|
873
|
+
if (options.createOnly)
|
|
874
|
+
query.set("create_only", "true");
|
|
875
|
+
if (options.atomic)
|
|
876
|
+
query.set("atomic", "true");
|
|
877
|
+
if (options.mode !== undefined)
|
|
878
|
+
query.set("mode", options.mode.toString(8));
|
|
879
|
+
if (query.size > 0) {
|
|
880
|
+
// An older agent ignores these; createOnly would replace a file.
|
|
881
|
+
await this.requireToolboxFeatures(id, FEATURE_WRITE_OPTIONS);
|
|
882
|
+
}
|
|
883
|
+
return `${this.versionPrefix}/sandboxes/${id}/toolbox/files/upload${query.size > 0 ? `?${query.toString()}` : ""}`;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Upload from a stream a piece at a time, so a large file never sits in
|
|
887
|
+
* memory. The options are {@link uploadFile}'s. The data is read as it is
|
|
888
|
+
* sent, so the request is sent once and never retried. Needs a `fetch` that
|
|
889
|
+
* can send a streamed body (Node.js 18 and later).
|
|
890
|
+
*/
|
|
891
|
+
async uploadFileStream(id, targetPath, data, options = {}) {
|
|
892
|
+
const path = await this.uploadPath(id, options);
|
|
893
|
+
const boundary = `----aerolvm-${newIdempotencyKey()}`;
|
|
894
|
+
const encoder = new TextEncoder();
|
|
895
|
+
const head = encoder.encode(`--${boundary}\r\nContent-Disposition: form-data; name="path"\r\n\r\n${targetPath}\r\n` +
|
|
896
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${basename(targetPath)}"\r\n` +
|
|
897
|
+
"Content-Type: application/octet-stream\r\n\r\n");
|
|
898
|
+
const tail = encoder.encode(`\r\n--${boundary}--\r\n`);
|
|
899
|
+
const parts = (async function* () {
|
|
900
|
+
yield head;
|
|
901
|
+
if (data instanceof ReadableStream) {
|
|
902
|
+
const reader = data.getReader();
|
|
903
|
+
for (;;) {
|
|
904
|
+
const { done, value } = await reader.read();
|
|
905
|
+
if (done)
|
|
906
|
+
break;
|
|
907
|
+
yield value;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
yield* data;
|
|
912
|
+
}
|
|
913
|
+
yield tail;
|
|
914
|
+
})();
|
|
915
|
+
const body = new ReadableStream({
|
|
916
|
+
async pull(controller) {
|
|
917
|
+
const next = await parts.next();
|
|
918
|
+
if (next.done)
|
|
919
|
+
controller.close();
|
|
920
|
+
else
|
|
921
|
+
controller.enqueue(next.value);
|
|
922
|
+
},
|
|
923
|
+
});
|
|
924
|
+
const init = {
|
|
925
|
+
body,
|
|
926
|
+
// Node's fetch sends a streamed body only with duplex "half".
|
|
927
|
+
duplex: "half",
|
|
928
|
+
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
|
|
929
|
+
};
|
|
930
|
+
const response = await this.request("POST", path, init, { idempotent: false, sendOnce: true });
|
|
518
931
|
if (!response.ok) {
|
|
519
932
|
throw await decodeError(response);
|
|
520
933
|
}
|
|
521
934
|
}
|
|
522
|
-
|
|
935
|
+
/**
|
|
936
|
+
* Copy the local folder `localDir` into `remoteDir` in the sandbox (made if
|
|
937
|
+
* missing). The folder is packed as a gzipped tar and the sandbox unpacks it
|
|
938
|
+
* safely: no entry can land outside `remoteDir`. Symlinks are sent as links,
|
|
939
|
+
* hard-linked files as separate files; devices, pipes and sockets are
|
|
940
|
+
* skipped. The archive is built in memory and sent once, never retried.
|
|
941
|
+
* Node.js only.
|
|
942
|
+
*/
|
|
943
|
+
async uploadDir(id, localDir, remoteDir) {
|
|
944
|
+
if (!(await stat(localDir)).isDirectory()) {
|
|
945
|
+
throw new Error(`${localDir} is not a folder`);
|
|
946
|
+
}
|
|
947
|
+
await this.requireToolboxFeatures(id, FEATURE_EXTRACT);
|
|
948
|
+
const archive = gzipSync(tarArchive(await folderEntries(localDir, "")));
|
|
949
|
+
const form = new FormData();
|
|
950
|
+
form.set("path", remoteDir);
|
|
951
|
+
form.set("file", new Blob([archive]), "upload.tar.gz");
|
|
952
|
+
const response = await this.request("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/files/upload?extract=true`, { body: form }, { idempotent: false, sendOnce: true });
|
|
953
|
+
if (!response.ok) {
|
|
954
|
+
throw await decodeError(response);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
async downloadFile(id, targetPath, options = {}) {
|
|
958
|
+
const stream = await this.downloadFileStream(id, targetPath, options);
|
|
959
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Open a file for reading a piece at a time, so a large file never sits in
|
|
963
|
+
* memory. With `maxBytes`, a larger file is refused before reading when the
|
|
964
|
+
* server states the size, and otherwise the stream errors as soon as more
|
|
965
|
+
* arrives. The request retries until the file starts to arrive.
|
|
966
|
+
*/
|
|
967
|
+
async downloadFileStream(id, targetPath, options = {}) {
|
|
523
968
|
const response = await this.request("GET", `${this.versionPrefix}/sandboxes/${id}/toolbox/files/download?path=${encodeURIComponent(targetPath)}`);
|
|
524
969
|
if (!response.ok) {
|
|
525
970
|
throw await decodeError(response);
|
|
526
971
|
}
|
|
527
|
-
|
|
972
|
+
const body = response.body ?? new Response(new Uint8Array()).body;
|
|
973
|
+
const max = options.maxBytes;
|
|
974
|
+
if (max === undefined) {
|
|
975
|
+
return body;
|
|
976
|
+
}
|
|
977
|
+
const length = response.headers.get("content-length");
|
|
978
|
+
if (length !== null && Number(length) > max) {
|
|
979
|
+
await body.cancel().catch(() => undefined);
|
|
980
|
+
throw new MicroVMFileTooLargeError(targetPath, max);
|
|
981
|
+
}
|
|
982
|
+
let seen = 0;
|
|
983
|
+
return body.pipeThrough(new TransformStream({
|
|
984
|
+
transform(chunk, controller) {
|
|
985
|
+
if (seen + chunk.length > max) {
|
|
986
|
+
controller.enqueue(chunk.subarray(0, max - seen));
|
|
987
|
+
controller.error(new MicroVMFileTooLargeError(targetPath, max));
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
seen += chunk.length;
|
|
991
|
+
controller.enqueue(chunk);
|
|
992
|
+
},
|
|
993
|
+
}));
|
|
994
|
+
}
|
|
995
|
+
// File verbs beyond upload and download (plans/agent-sandbox-feature-
|
|
996
|
+
// parity.md, F1), through the sandbox's toolbox. A sandbox whose agent
|
|
997
|
+
// predates them fails closed with MicroVMUnsupportedFeatureError instead of
|
|
998
|
+
// ignoring a safety option; WASM and V8 isolate sandboxes answer 501.
|
|
999
|
+
//
|
|
1000
|
+
// Retries: listFiles, getFileInfo, fileExists, searchFiles, findInFiles,
|
|
1001
|
+
// deleteFile and makeDir are safe to re-send and retry on 421, 429, 502,
|
|
1002
|
+
// 503 and 504 (a retried delete or mkdir whose work is already done counts
|
|
1003
|
+
// as success). moveFile and copyFile retry only on 429 and 503, because a
|
|
1004
|
+
// lost reply may hide a move that happened.
|
|
1005
|
+
toolboxPath(id, route, query) {
|
|
1006
|
+
return `${this.versionPrefix}/sandboxes/${encodeURIComponent(id)}/toolbox${route}?${new URLSearchParams(query).toString()}`;
|
|
1007
|
+
}
|
|
1008
|
+
/** Sends a body-less file call and discards a success reply. */
|
|
1009
|
+
async fileCall(method, path, options) {
|
|
1010
|
+
const response = await this.request(method, path, {}, options);
|
|
1011
|
+
if (!response.ok) {
|
|
1012
|
+
throw await decodeError(response);
|
|
1013
|
+
}
|
|
1014
|
+
await response.body?.cancel().catch(() => undefined);
|
|
1015
|
+
}
|
|
1016
|
+
async listFiles(id, path, options = {}) {
|
|
1017
|
+
const depth = options.depth ?? 1;
|
|
1018
|
+
const limit = options.limit ?? DEFAULT_LIST_LIMIT;
|
|
1019
|
+
if (depth < 1 || limit < 1) {
|
|
1020
|
+
throw new Error("depth and limit must be at least 1");
|
|
1021
|
+
}
|
|
1022
|
+
await this.requireToolboxFeatures(id, FEATURE_FILE_VERBS);
|
|
1023
|
+
const response = await this.request("GET", this.toolboxPath(id, "/files", { path, depth: String(depth), limit: String(limit), extended: "true" }));
|
|
1024
|
+
if (!response.ok) {
|
|
1025
|
+
throw await decodeError(response);
|
|
1026
|
+
}
|
|
1027
|
+
const items = ((await response.json()) ?? []);
|
|
1028
|
+
const base = path.replace(/\/+$/, "");
|
|
1029
|
+
return {
|
|
1030
|
+
entries: items.map((item) => fromApiFileInfo(item, `${base}/${item.name ?? ""}`)),
|
|
1031
|
+
truncated: response.headers.get("x-toolbox-truncated") === "true",
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
async getFileInfo(id, path) {
|
|
1035
|
+
// extended=true only adds fields; an older agent that ignores it answers
|
|
1036
|
+
// fields this still reads, so no /version check.
|
|
1037
|
+
const item = await this.doJSON("GET", this.toolboxPath(id, "/files/info", { path, extended: "true" }));
|
|
1038
|
+
return fromApiFileInfo(item ?? {}, path);
|
|
1039
|
+
}
|
|
1040
|
+
async fileExists(id, path) {
|
|
1041
|
+
try {
|
|
1042
|
+
await this.getFileInfo(id, path);
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
catch (err) {
|
|
1046
|
+
if (isMissingFile(err)) {
|
|
1047
|
+
return false;
|
|
1048
|
+
}
|
|
1049
|
+
throw err;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
async deleteFile(id, path, options = {}) {
|
|
1053
|
+
const query = { path };
|
|
1054
|
+
if (options.recursive) {
|
|
1055
|
+
query.recursive = "true";
|
|
1056
|
+
}
|
|
1057
|
+
await this.fileCall("DELETE", this.toolboxPath(id, "/files", query), { doneCode: "file_not_found" });
|
|
1058
|
+
}
|
|
1059
|
+
async makeDir(id, path, options = {}) {
|
|
1060
|
+
const parents = options.parents ?? false;
|
|
1061
|
+
if (!parents) {
|
|
1062
|
+
// An older agent ignores parents=false and makes them anyway.
|
|
1063
|
+
await this.requireToolboxFeatures(id, FEATURE_FILE_VERBS);
|
|
1064
|
+
}
|
|
1065
|
+
const query = { path, parents: String(parents) };
|
|
1066
|
+
if (options.mode !== undefined) {
|
|
1067
|
+
query.mode = options.mode.toString(8);
|
|
1068
|
+
}
|
|
1069
|
+
await this.fileCall("POST", this.toolboxPath(id, "/files/folder", query), { doneCode: "folder_exists" });
|
|
1070
|
+
}
|
|
1071
|
+
async moveFile(id, source, destination, options = {}) {
|
|
1072
|
+
const overwrite = options.overwrite ?? false;
|
|
1073
|
+
if (!overwrite) {
|
|
1074
|
+
// An older agent ignores overwrite=false and replaces the destination.
|
|
1075
|
+
await this.requireToolboxFeatures(id, FEATURE_FILE_VERBS);
|
|
1076
|
+
}
|
|
1077
|
+
await this.fileCall("POST", this.toolboxPath(id, "/files/move", { source, destination, overwrite: String(overwrite) }), { idempotent: false });
|
|
1078
|
+
}
|
|
1079
|
+
async copyFile(id, source, destination, options = {}) {
|
|
1080
|
+
await this.requireToolboxFeatures(id, FEATURE_FILE_VERBS);
|
|
1081
|
+
const query = {
|
|
1082
|
+
source,
|
|
1083
|
+
destination,
|
|
1084
|
+
overwrite: String(options.overwrite ?? false),
|
|
1085
|
+
recursive: String(options.recursive ?? false),
|
|
1086
|
+
};
|
|
1087
|
+
await this.fileCall("POST", this.toolboxPath(id, "/files/copy", query), { idempotent: false });
|
|
1088
|
+
}
|
|
1089
|
+
async searchFiles(id, path, pattern) {
|
|
1090
|
+
const response = await this.doJSON("GET", this.toolboxPath(id, "/files/search", { path, pattern }));
|
|
1091
|
+
return Array.isArray(response?.files) ? response.files.map(String) : [];
|
|
1092
|
+
}
|
|
1093
|
+
async findInFiles(id, path, text) {
|
|
1094
|
+
const response = await this.doJSON("GET", this.toolboxPath(id, "/files/find", { path, pattern: text }));
|
|
1095
|
+
if (!Array.isArray(response)) {
|
|
1096
|
+
return [];
|
|
1097
|
+
}
|
|
1098
|
+
return response.map((m) => ({
|
|
1099
|
+
file: String(m.file ?? ""),
|
|
1100
|
+
line: Number(m.line ?? 0),
|
|
1101
|
+
content: String(m.content ?? ""),
|
|
1102
|
+
}));
|
|
528
1103
|
}
|
|
529
1104
|
/**
|
|
530
1105
|
* Publish a sandbox container port. The chosen wire protocol is selected by
|
|
@@ -540,13 +1115,60 @@ export class APIClient {
|
|
|
540
1115
|
* variant carries `host` / `hostPort` — everything else is in `url`.
|
|
541
1116
|
*/
|
|
542
1117
|
async exposePort(id, port, options = {}) {
|
|
543
|
-
|
|
1118
|
+
// Only what the caller set: an omitted visibility keeps an exposed
|
|
1119
|
+
// port's gate, so a plain retry never opens a token port.
|
|
1120
|
+
const fields = {};
|
|
1121
|
+
if (options.protocol) {
|
|
1122
|
+
fields.protocol = options.protocol;
|
|
1123
|
+
}
|
|
1124
|
+
if (options.visibility) {
|
|
1125
|
+
fields.visibility = options.visibility;
|
|
1126
|
+
}
|
|
1127
|
+
const body = Object.keys(fields).length > 0 ? fields : undefined;
|
|
544
1128
|
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/ports/${port}`, body);
|
|
545
1129
|
return fromApiExposePortResponse(response);
|
|
546
1130
|
}
|
|
547
1131
|
async unexposePort(id, port) {
|
|
548
1132
|
await this.doJSON("DELETE", `${this.versionPrefix}/sandboxes/${id}/ports/${port}`);
|
|
549
1133
|
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Issue a signed token scoped to one sandbox (POST /v1/sandboxes/{id}/tokens).
|
|
1136
|
+
* Not idempotent by nature: every call makes a new token, and the server
|
|
1137
|
+
* stores none. It uses the normal retry tier: a retry after a lost answer
|
|
1138
|
+
* makes a second token that nobody holds, which simply expires.
|
|
1139
|
+
*/
|
|
1140
|
+
async createSandboxToken(id, options) {
|
|
1141
|
+
const body = { expires_in: options.expiresIn };
|
|
1142
|
+
if (options.port !== undefined && options.port !== 0) {
|
|
1143
|
+
body.port = options.port;
|
|
1144
|
+
}
|
|
1145
|
+
if (options.pathPrefix) {
|
|
1146
|
+
body.path_prefix = options.pathPrefix;
|
|
1147
|
+
}
|
|
1148
|
+
if (options.methods && options.methods.length > 0) {
|
|
1149
|
+
body.methods = options.methods;
|
|
1150
|
+
}
|
|
1151
|
+
const r = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/tokens`, body);
|
|
1152
|
+
return {
|
|
1153
|
+
token: r.token,
|
|
1154
|
+
tokenID: r.token_id,
|
|
1155
|
+
sandboxID: r.sandbox_id,
|
|
1156
|
+
...(r.port ? { port: r.port } : {}),
|
|
1157
|
+
...(r.path_prefix ? { pathPrefix: r.path_prefix } : {}),
|
|
1158
|
+
...(r.methods && r.methods.length > 0 ? { methods: r.methods } : {}),
|
|
1159
|
+
expiresAt: r.expires_at,
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Make every token issued for one sandbox so far stop working
|
|
1164
|
+
* (POST /v1/sandboxes/{id}/tokens/revoke). A token issued after it works.
|
|
1165
|
+
* Safe to retry: a repeated call refuses the same tokens again (plus any
|
|
1166
|
+
* issued in between), so it uses the normal retry tier.
|
|
1167
|
+
*/
|
|
1168
|
+
async revokeSandboxTokens(id) {
|
|
1169
|
+
const r = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/tokens/revoke`);
|
|
1170
|
+
return { sandboxID: r.sandbox_id, tokensNotBefore: r.tokens_not_before };
|
|
1171
|
+
}
|
|
550
1172
|
/**
|
|
551
1173
|
* Bind a custom hostname to a sandbox. The server lowercases the hostname
|
|
552
1174
|
* and returns the post-add list of bindings (sorted by hostname). Calling
|
|
@@ -628,6 +1250,8 @@ export class APIClient {
|
|
|
628
1250
|
params.set("cursor", options.cursor);
|
|
629
1251
|
if (options.incarnationID)
|
|
630
1252
|
params.set("incarnation_id", options.incarnationID);
|
|
1253
|
+
if (options.caller)
|
|
1254
|
+
params.set("caller", options.caller);
|
|
631
1255
|
const query = params.toString();
|
|
632
1256
|
const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/audit${query ? `?${query}` : ""}`);
|
|
633
1257
|
return fromApiAuditPage(response);
|
|
@@ -698,6 +1322,68 @@ export class APIClient {
|
|
|
698
1322
|
async deleteEgressProfile(name) {
|
|
699
1323
|
await this.doJSON("DELETE", this.versioned(`/egress-profiles/${encodeURIComponent(name)}`));
|
|
700
1324
|
}
|
|
1325
|
+
// Warm pools are PUT/GET/DELETE of one entry: every call is safe to retry,
|
|
1326
|
+
// so they keep the default (idempotent) retry tier.
|
|
1327
|
+
async putWarmPool(image, options) {
|
|
1328
|
+
return fromApiWarmPool(await this.doJSON("PUT", this.versioned(warmPoolPath(image)), {
|
|
1329
|
+
depth: options.depth ?? 0,
|
|
1330
|
+
min_depth: options.minDepth,
|
|
1331
|
+
max_depth: options.maxDepth,
|
|
1332
|
+
on_image_change: options.onImageChange,
|
|
1333
|
+
}));
|
|
1334
|
+
}
|
|
1335
|
+
async getWarmPool(image) {
|
|
1336
|
+
return fromApiWarmPool(await this.doJSON("GET", this.versioned(warmPoolPath(image))));
|
|
1337
|
+
}
|
|
1338
|
+
async listWarmPools() {
|
|
1339
|
+
const response = await this.doJSON("GET", this.versioned("/warm-pools"));
|
|
1340
|
+
return (response.pools ?? []).map(fromApiWarmPool);
|
|
1341
|
+
}
|
|
1342
|
+
async deleteWarmPool(image) {
|
|
1343
|
+
await this.doJSON("DELETE", this.versioned(warmPoolPath(image)));
|
|
1344
|
+
}
|
|
1345
|
+
async putIdentityRole(tag, options) {
|
|
1346
|
+
const response = await this.doJSON("PUT", this.identityRolePath(tag, options.team), {
|
|
1347
|
+
min_role: options.minRole,
|
|
1348
|
+
});
|
|
1349
|
+
return fromApiIdentityRole(response);
|
|
1350
|
+
}
|
|
1351
|
+
async listIdentityRoles(options = {}) {
|
|
1352
|
+
const params = new URLSearchParams();
|
|
1353
|
+
if (options.team)
|
|
1354
|
+
params.set("team", options.team);
|
|
1355
|
+
if (options.cursor)
|
|
1356
|
+
params.set("cursor", options.cursor);
|
|
1357
|
+
if (options.limit)
|
|
1358
|
+
params.set("limit", String(options.limit));
|
|
1359
|
+
const query = params.toString();
|
|
1360
|
+
const response = await this.doJSON("GET", this.versioned(`/identity/roles${query ? `?${query}` : ""}`));
|
|
1361
|
+
const list = { roles: (response.roles ?? []).map(fromApiIdentityRole) };
|
|
1362
|
+
if (response.next_cursor)
|
|
1363
|
+
list.nextCursor = response.next_cursor;
|
|
1364
|
+
return list;
|
|
1365
|
+
}
|
|
1366
|
+
async deleteIdentityRole(tag, options = {}) {
|
|
1367
|
+
await this.doJSON("DELETE", this.identityRolePath(tag, options.team));
|
|
1368
|
+
}
|
|
1369
|
+
identityRolePath(tag, team) {
|
|
1370
|
+
const path = `/identity/roles/${encodeURIComponent(tag)}`;
|
|
1371
|
+
return this.versioned(team ? `${path}?team=${encodeURIComponent(team)}` : path);
|
|
1372
|
+
}
|
|
1373
|
+
async putTeamQuota(team, options) {
|
|
1374
|
+
const response = await this.doJSON("PUT", this.versioned(`/teams/${encodeURIComponent(team)}/quota`), {
|
|
1375
|
+
max_sandboxes: options.maxSandboxes,
|
|
1376
|
+
max_cpu: options.maxCPU,
|
|
1377
|
+
max_memory_mb: options.maxMemoryMB,
|
|
1378
|
+
});
|
|
1379
|
+
return fromApiTeamQuota(response);
|
|
1380
|
+
}
|
|
1381
|
+
async getTeamQuota(team) {
|
|
1382
|
+
return fromApiTeamQuota(await this.doJSON("GET", this.versioned(`/teams/${encodeURIComponent(team)}/quota`)));
|
|
1383
|
+
}
|
|
1384
|
+
async deleteTeamQuota(team) {
|
|
1385
|
+
await this.doJSON("DELETE", this.versioned(`/teams/${encodeURIComponent(team)}/quota`));
|
|
1386
|
+
}
|
|
701
1387
|
async createTemplate(options) {
|
|
702
1388
|
const response = await this.doJSON("POST", this.versioned("/templates"), {
|
|
703
1389
|
id: options.id,
|
|
@@ -785,7 +1471,7 @@ export class APIClient {
|
|
|
785
1471
|
await this.doJSON("DELETE", this.versioned(`/tokens/${encodeURIComponent(trimmed)}`));
|
|
786
1472
|
}
|
|
787
1473
|
wrap(sandbox) {
|
|
788
|
-
return new SandboxResource(this, fromApiSandbox(sandbox));
|
|
1474
|
+
return new SandboxResource(this.root ?? this, fromApiSandbox(sandbox));
|
|
789
1475
|
}
|
|
790
1476
|
async doJSON(method, path, body, options) {
|
|
791
1477
|
const init = { headers: { ...options?.headers } };
|
|
@@ -809,6 +1495,16 @@ export class APIClient {
|
|
|
809
1495
|
}
|
|
810
1496
|
return new Uint8Array(await response.arrayBuffer());
|
|
811
1497
|
}
|
|
1498
|
+
authHeaders(base) {
|
|
1499
|
+
const headers = new Headers(base);
|
|
1500
|
+
if (this.patToken !== "") {
|
|
1501
|
+
headers.set("Authorization", `Bearer ${this.patToken}`);
|
|
1502
|
+
}
|
|
1503
|
+
if (this.team !== "") {
|
|
1504
|
+
headers.set(TEAM_HEADER, this.team);
|
|
1505
|
+
}
|
|
1506
|
+
return headers;
|
|
1507
|
+
}
|
|
812
1508
|
/**
|
|
813
1509
|
* Low-level HTTP request with automatic retry for transient failures.
|
|
814
1510
|
*
|
|
@@ -827,27 +1523,105 @@ export class APIClient {
|
|
|
827
1523
|
* A 429 or 503 waits as long as its `Retry-After` asks (see
|
|
828
1524
|
* {@link retryDelayMs}). The client's `signal` ends the request and any
|
|
829
1525
|
* wait.
|
|
1526
|
+
*
|
|
1527
|
+
* A 421 that names another cell ({@link CELL_ENDPOINT_HEADER}) is not
|
|
1528
|
+
* retried in place: the same request goes once to that cell instead, and
|
|
1529
|
+
* later requests for that sandbox go straight there.
|
|
830
1530
|
*/
|
|
831
1531
|
async request(method, path, init = {}, options = {}) {
|
|
832
|
-
const headers =
|
|
833
|
-
if (this.patToken !== "") {
|
|
834
|
-
headers.set("Authorization", `Bearer ${this.patToken}`);
|
|
835
|
-
}
|
|
836
|
-
if (this.team !== "") {
|
|
837
|
-
headers.set(TEAM_HEADER, this.team);
|
|
838
|
-
}
|
|
839
|
-
const url = `${this.baseURL}${path}`;
|
|
1532
|
+
const headers = this.authHeaders(init.headers);
|
|
840
1533
|
const signal = init.signal ?? this.signal;
|
|
841
1534
|
const requestInit = { ...init, method, headers, ...(signal ? { signal } : {}) };
|
|
842
|
-
const
|
|
1535
|
+
const sandboxID = sandboxIDInPath(path, this.versioned("/sandboxes/"));
|
|
1536
|
+
let base = this.baseURLFor(sandboxID);
|
|
1537
|
+
let response = await this.send(`${base}${path}`, requestInit, options);
|
|
1538
|
+
const cell = this.cellEndpoint(response);
|
|
1539
|
+
if (cell !== undefined) {
|
|
1540
|
+
// Remembered even when the body cannot go again, so the caller's own
|
|
1541
|
+
// retry goes to the right cell.
|
|
1542
|
+
if (sandboxID !== undefined)
|
|
1543
|
+
this.rememberCell(sandboxID, cell);
|
|
1544
|
+
// The cell router answers before any work is done, so even an exec or
|
|
1545
|
+
// a folder upload may go again; only a body that fetch has already
|
|
1546
|
+
// read cannot.
|
|
1547
|
+
if (!isReplayableBody(init.body))
|
|
1548
|
+
return response;
|
|
1549
|
+
response.body?.cancel().catch(() => undefined);
|
|
1550
|
+
base = cell;
|
|
1551
|
+
// send hands back a second 421 naming a cell as it is, and nothing
|
|
1552
|
+
// follows it: two cells that disagree cannot bounce a request between
|
|
1553
|
+
// them.
|
|
1554
|
+
response = await this.send(`${base}${path}`, requestInit, options);
|
|
1555
|
+
}
|
|
1556
|
+
// A 404 from the remembered cell may mean the sandbox was deleted, so the
|
|
1557
|
+
// next call asks the configured server again. A missing file says nothing
|
|
1558
|
+
// about the sandbox, and forgetting on it would add a 421 to every poll.
|
|
1559
|
+
if (response.status === 404 &&
|
|
1560
|
+
sandboxID !== undefined &&
|
|
1561
|
+
this.sandboxCells.get(sandboxID) === base &&
|
|
1562
|
+
!isMissingFile(await decodeError(response.clone()))) {
|
|
1563
|
+
this.sandboxCells.delete(sandboxID);
|
|
1564
|
+
}
|
|
1565
|
+
return response;
|
|
1566
|
+
}
|
|
1567
|
+
/** Where requests for sandbox `id` go: the cell it was found in, else the configured server. */
|
|
1568
|
+
baseURLFor(id) {
|
|
1569
|
+
return (id !== undefined && this.sandboxCells.get(id)) || this.baseURL;
|
|
1570
|
+
}
|
|
1571
|
+
rememberCell(id, cell) {
|
|
1572
|
+
// Re-inserted so a re-learned entry counts as the newest.
|
|
1573
|
+
this.sandboxCells.delete(id);
|
|
1574
|
+
this.sandboxCells.set(id, cell);
|
|
1575
|
+
if (this.sandboxCells.size > MAX_REMEMBERED_CELLS) {
|
|
1576
|
+
const oldest = this.sandboxCells.keys().next().value;
|
|
1577
|
+
if (oldest !== undefined)
|
|
1578
|
+
this.sandboxCells.delete(oldest);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* The cell base URL a 421 names, or `undefined` when it names none this
|
|
1583
|
+
* client may send its token to: not an absolute http(s) URL, or plain http
|
|
1584
|
+
* while the client was configured for https, which would let one bad
|
|
1585
|
+
* answer move the token to clear text.
|
|
1586
|
+
*/
|
|
1587
|
+
cellEndpoint(response) {
|
|
1588
|
+
if (!isCellAnswer(response))
|
|
1589
|
+
return undefined;
|
|
1590
|
+
let url;
|
|
1591
|
+
try {
|
|
1592
|
+
url = new URL((response.headers.get(CELL_ENDPOINT_HEADER) ?? "").trim());
|
|
1593
|
+
}
|
|
1594
|
+
catch {
|
|
1595
|
+
return undefined;
|
|
1596
|
+
}
|
|
1597
|
+
if (url.host === "" || (url.protocol !== "https:" && (url.protocol !== "http:" || /^https:/i.test(this.baseURL)))) {
|
|
1598
|
+
return undefined;
|
|
1599
|
+
}
|
|
1600
|
+
// Built like the configured base URL: the cell's own path prefix stays,
|
|
1601
|
+
// so /v1/... lands under it. A query, fragment or userinfo has no place
|
|
1602
|
+
// in a base URL and is dropped.
|
|
1603
|
+
return url.origin + stripTrailingSlashes(url.pathname);
|
|
1604
|
+
}
|
|
1605
|
+
/** One request to `url` with the retry loop described on {@link request}. */
|
|
1606
|
+
async send(url, requestInit, options) {
|
|
1607
|
+
const signal = requestInit.signal ?? undefined;
|
|
1608
|
+
const maxRetries = options.sendOnce ? 0 : this.retryConfig.maxRetries;
|
|
843
1609
|
const idempotent = options.idempotent ?? true;
|
|
844
1610
|
const retryableStatusCodes = idempotent ? RETRYABLE_STATUS_CODES : REFUSED_STATUS_CODES;
|
|
845
1611
|
const isRetryableError = idempotent ? isTransientTransportError : isUnsentTransportError;
|
|
1612
|
+
let mayHaveRun = false;
|
|
846
1613
|
for (let attempt = 0;; attempt++) {
|
|
847
1614
|
let retryAfter = null;
|
|
848
1615
|
try {
|
|
849
1616
|
const response = await this.fetchFn(url, requestInit);
|
|
850
|
-
if (!
|
|
1617
|
+
if (mayHaveRun && options.doneCode && !response.ok && (await errorCode(response)) === options.doneCode) {
|
|
1618
|
+
response.body?.cancel().catch(() => undefined);
|
|
1619
|
+
return new Response(null, { status: 204 });
|
|
1620
|
+
}
|
|
1621
|
+
if (response.status === 502 || response.status === 504) {
|
|
1622
|
+
mayHaveRun = true;
|
|
1623
|
+
}
|
|
1624
|
+
if (!retryableStatusCodes.has(response.status) || attempt >= maxRetries || isCellAnswer(response)) {
|
|
851
1625
|
return response;
|
|
852
1626
|
}
|
|
853
1627
|
if (REFUSED_STATUS_CODES.has(response.status)) {
|
|
@@ -862,24 +1636,56 @@ export class APIClient {
|
|
|
862
1636
|
if (signal?.aborted || !isRetryableError(error) || attempt >= maxRetries) {
|
|
863
1637
|
throw error;
|
|
864
1638
|
}
|
|
1639
|
+
mayHaveRun ||= !isUnsentTransportError(error);
|
|
865
1640
|
}
|
|
866
1641
|
await sleep(retryDelayMs(this.retryConfig, attempt, retryAfter, Date.now(), Math.random()), signal);
|
|
867
1642
|
}
|
|
868
1643
|
}
|
|
869
1644
|
}
|
|
870
1645
|
export class SandboxResource {
|
|
871
|
-
|
|
1646
|
+
/**
|
|
1647
|
+
* When true, leaving an `await using` block deletes the sandbox (see
|
|
1648
|
+
* `[Symbol.asyncDispose]`). Off by default, so the block ends without
|
|
1649
|
+
* touching it; set it here or with `destroyOnExit` on `create` and
|
|
1650
|
+
* `getOrCreate`.
|
|
1651
|
+
*/
|
|
1652
|
+
destroyOnExit = false;
|
|
1653
|
+
disposed = false;
|
|
1654
|
+
/** The client for one call: the real one, or a view carrying the call's signal. */
|
|
1655
|
+
api(call) {
|
|
1656
|
+
return this.client.withSignal(call?.signal);
|
|
1657
|
+
}
|
|
872
1658
|
constructor(client, sandbox) {
|
|
873
|
-
|
|
1659
|
+
// Not enumerable: it holds the token, so it must never reach toJSON,
|
|
1660
|
+
// JSON.stringify or a log of the sandbox.
|
|
1661
|
+
Object.defineProperty(this, "client", { value: client, enumerable: false });
|
|
874
1662
|
this.apply(sandbox);
|
|
875
1663
|
}
|
|
876
|
-
|
|
877
|
-
|
|
1664
|
+
/**
|
|
1665
|
+
* Called when an `await using` block holding this sandbox ends. Deletes the
|
|
1666
|
+
* sandbox only when {@link destroyOnExit} is true; otherwise does nothing.
|
|
1667
|
+
* A sandbox that is already gone counts as deleted, and a second call does
|
|
1668
|
+
* nothing.
|
|
1669
|
+
*/
|
|
1670
|
+
async [Symbol.asyncDispose]() {
|
|
1671
|
+
if (!this.destroyOnExit || this.disposed)
|
|
1672
|
+
return;
|
|
1673
|
+
try {
|
|
1674
|
+
await this.client.destroy(this.id);
|
|
1675
|
+
}
|
|
1676
|
+
catch (err) {
|
|
1677
|
+
if (!(err instanceof MicroVMNotFoundError))
|
|
1678
|
+
throw err;
|
|
1679
|
+
}
|
|
1680
|
+
this.disposed = true;
|
|
1681
|
+
}
|
|
1682
|
+
async refresh(call) {
|
|
1683
|
+
const updated = await this.api(call).get(this.id);
|
|
878
1684
|
this.apply(updated.toJSON());
|
|
879
1685
|
return this;
|
|
880
1686
|
}
|
|
881
|
-
async exec(command) {
|
|
882
|
-
return this.
|
|
1687
|
+
async exec(command, call) {
|
|
1688
|
+
return this.api(call).exec(this.id, typeof command === "string" ? { command } : command);
|
|
883
1689
|
}
|
|
884
1690
|
/**
|
|
885
1691
|
* Read this sandbox's clone-generation token. The token changes whenever the
|
|
@@ -887,50 +1693,122 @@ export class SandboxResource {
|
|
|
887
1693
|
* clone (e.g. to re-run setup). It does NOT reseed in-guest PRNGs — see the
|
|
888
1694
|
* "Randomness in cloned sandboxes" docs page for the in-guest pattern.
|
|
889
1695
|
*/
|
|
890
|
-
async cloneGeneration() {
|
|
891
|
-
return this.
|
|
1696
|
+
async cloneGeneration(call) {
|
|
1697
|
+
return this.api(call).cloneGeneration(this.id);
|
|
892
1698
|
}
|
|
893
1699
|
execStream(options) {
|
|
894
1700
|
return this.client.execStream(this.id, options);
|
|
895
1701
|
}
|
|
896
|
-
|
|
897
|
-
|
|
1702
|
+
/**
|
|
1703
|
+
* Watch a folder in this sandbox for changes; see
|
|
1704
|
+
* {@link APIClient.watchFiles}. gVisor and Firecracker sandboxes only.
|
|
1705
|
+
*/
|
|
1706
|
+
async watchFiles(path, onEvent, options = {}) {
|
|
1707
|
+
return this.client.watchFiles(this.id, path, onEvent, options);
|
|
1708
|
+
}
|
|
1709
|
+
async createSession(options, call) {
|
|
1710
|
+
return this.api(call).createSession(this.id, options);
|
|
898
1711
|
}
|
|
899
|
-
async listSessions() {
|
|
900
|
-
return this.
|
|
1712
|
+
async listSessions(call) {
|
|
1713
|
+
return this.api(call).listSessions(this.id);
|
|
901
1714
|
}
|
|
902
|
-
async getSession(sessionID) {
|
|
903
|
-
return this.
|
|
1715
|
+
async getSession(sessionID, call) {
|
|
1716
|
+
return this.api(call).getSession(this.id, sessionID);
|
|
904
1717
|
}
|
|
905
|
-
async deleteSession(sessionID) {
|
|
906
|
-
await this.
|
|
1718
|
+
async deleteSession(sessionID, call) {
|
|
1719
|
+
await this.api(call).deleteSession(this.id, sessionID);
|
|
907
1720
|
}
|
|
908
|
-
async signalSession(sessionID, signal) {
|
|
909
|
-
await this.
|
|
1721
|
+
async signalSession(sessionID, signal, call) {
|
|
1722
|
+
await this.api(call).signalSession(this.id, sessionID, signal);
|
|
910
1723
|
}
|
|
911
|
-
async resizeSession(sessionID, cols, rows) {
|
|
912
|
-
await this.
|
|
1724
|
+
async resizeSession(sessionID, cols, rows, call) {
|
|
1725
|
+
await this.api(call).resizeSession(this.id, sessionID, cols, rows);
|
|
913
1726
|
}
|
|
914
|
-
async sessionLog(sessionID) {
|
|
915
|
-
return this.
|
|
1727
|
+
async sessionLog(sessionID, call) {
|
|
1728
|
+
return this.api(call).sessionLog(this.id, sessionID);
|
|
916
1729
|
}
|
|
917
|
-
async sessionRecording(sessionID) {
|
|
918
|
-
return this.
|
|
1730
|
+
async sessionRecording(sessionID, call) {
|
|
1731
|
+
return this.api(call).sessionRecording(this.id, sessionID);
|
|
919
1732
|
}
|
|
920
1733
|
attachSession(sessionID, options = {}) {
|
|
921
1734
|
return this.client.attachSession(this.id, sessionID, options);
|
|
922
1735
|
}
|
|
923
|
-
|
|
924
|
-
|
|
1736
|
+
/** Copy a local folder into the sandbox (Node.js only). See {@link APIClient.uploadDir}. */
|
|
1737
|
+
async uploadDir(localDir, remoteDir, call) {
|
|
1738
|
+
await this.api(call).uploadDir(this.id, localDir, remoteDir);
|
|
1739
|
+
}
|
|
1740
|
+
/** Upload a file. See {@link UploadFileOptions} for append, create-only, atomic and mode. */
|
|
1741
|
+
async uploadFile(targetPath, data, options = {}, call) {
|
|
1742
|
+
await this.api(call).uploadFile(this.id, targetPath, data, options);
|
|
1743
|
+
}
|
|
1744
|
+
async downloadFile(targetPath, options = {}, call) {
|
|
1745
|
+
return this.api(call).downloadFile(this.id, targetPath, options);
|
|
1746
|
+
}
|
|
1747
|
+
/** Read a file as a stream. See {@link APIClient.downloadFileStream}. */
|
|
1748
|
+
async downloadFileStream(targetPath, options = {}, call) {
|
|
1749
|
+
return this.api(call).downloadFileStream(this.id, targetPath, options);
|
|
1750
|
+
}
|
|
1751
|
+
/** Upload from a stream, sent once. See {@link APIClient.uploadFileStream}. */
|
|
1752
|
+
async uploadFileStream(targetPath, data, options = {}, call) {
|
|
1753
|
+
await this.api(call).uploadFileStream(this.id, targetPath, data, options);
|
|
925
1754
|
}
|
|
926
|
-
|
|
927
|
-
|
|
1755
|
+
/** List a folder. See {@link ListFilesOptions}; `truncated` means more entries exist. */
|
|
1756
|
+
async listFiles(path, options = {}, call) {
|
|
1757
|
+
return this.api(call).listFiles(this.id, path, options);
|
|
928
1758
|
}
|
|
929
|
-
|
|
930
|
-
|
|
1759
|
+
/** Describe one path. A symlink is followed. */
|
|
1760
|
+
async getFileInfo(path, call) {
|
|
1761
|
+
return this.api(call).getFileInfo(this.id, path);
|
|
931
1762
|
}
|
|
932
|
-
|
|
933
|
-
|
|
1763
|
+
/** Whether a path exists. A missing sandbox throws; it is not `false`. */
|
|
1764
|
+
async fileExists(path, call) {
|
|
1765
|
+
return this.api(call).fileExists(this.id, path);
|
|
1766
|
+
}
|
|
1767
|
+
/** Delete a file, or a folder and its contents with `recursive: true`. */
|
|
1768
|
+
async deleteFile(path, options = {}, call) {
|
|
1769
|
+
await this.api(call).deleteFile(this.id, path, options);
|
|
1770
|
+
}
|
|
1771
|
+
/** Make a folder; with `parents: true`, like `mkdir -p`. */
|
|
1772
|
+
async makeDir(path, options = {}, call) {
|
|
1773
|
+
await this.api(call).makeDir(this.id, path, options);
|
|
1774
|
+
}
|
|
1775
|
+
/** Move or rename a file or folder. Without `overwrite`, an existing destination is refused (409). */
|
|
1776
|
+
async moveFile(source, destination, options = {}, call) {
|
|
1777
|
+
await this.api(call).moveFile(this.id, source, destination, options);
|
|
1778
|
+
}
|
|
1779
|
+
/** Copy a file, or a folder with `recursive: true`. */
|
|
1780
|
+
async copyFile(source, destination, options = {}, call) {
|
|
1781
|
+
await this.api(call).copyFile(this.id, source, destination, options);
|
|
1782
|
+
}
|
|
1783
|
+
/** Paths under `path` whose name matches a glob such as `*.ts`. */
|
|
1784
|
+
async searchFiles(path, pattern, call) {
|
|
1785
|
+
return this.api(call).searchFiles(this.id, path, pattern);
|
|
1786
|
+
}
|
|
1787
|
+
/** Each line under `path` that contains `text` (plain text). */
|
|
1788
|
+
async findInFiles(path, text, call) {
|
|
1789
|
+
return this.api(call).findInFiles(this.id, path, text);
|
|
1790
|
+
}
|
|
1791
|
+
async exposePort(port, options, call) {
|
|
1792
|
+
return this.api(call).exposePort(this.id, port, options);
|
|
1793
|
+
}
|
|
1794
|
+
async unexposePort(port, call) {
|
|
1795
|
+
await this.api(call).unexposePort(this.id, port);
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Issue a signed token for this sandbox only, to hand to a browser or an
|
|
1799
|
+
* end user. See {@link CreateSandboxTokenOptions}. Each call makes a new
|
|
1800
|
+
* token; the server keeps no copy.
|
|
1801
|
+
*/
|
|
1802
|
+
async createToken(options, call) {
|
|
1803
|
+
return this.api(call).createSandboxToken(this.id, options);
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Make every token issued for this sandbox so far stop working, toolbox
|
|
1807
|
+
* and port tokens alike. A token made after the call works. Safe to call
|
|
1808
|
+
* again.
|
|
1809
|
+
*/
|
|
1810
|
+
async revokeTokens(call) {
|
|
1811
|
+
return this.api(call).revokeSandboxTokens(this.id);
|
|
934
1812
|
}
|
|
935
1813
|
/**
|
|
936
1814
|
* Per-sandbox custom-hostname bindings. Exposed as a namespaced accessor
|
|
@@ -944,51 +1822,51 @@ export class SandboxResource {
|
|
|
944
1822
|
const client = this.client;
|
|
945
1823
|
const id = this.id;
|
|
946
1824
|
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),
|
|
1825
|
+
add: (hostname, options, call) => client.withSignal(call?.signal).addCustomDomain(id, hostname, options),
|
|
1826
|
+
remove: (hostname, call) => client.withSignal(call?.signal).removeCustomDomain(id, hostname),
|
|
1827
|
+
list: (call) => client.withSignal(call?.signal).listCustomDomains(id),
|
|
1828
|
+
dns: (call) => client.withSignal(call?.signal).customDomainDNS(id),
|
|
951
1829
|
};
|
|
952
1830
|
}
|
|
953
|
-
async createSnapshot(name, options = {}) {
|
|
954
|
-
return this.
|
|
1831
|
+
async createSnapshot(name, options = {}, call) {
|
|
1832
|
+
return this.api(call).createSnapshot(this.id, name, options);
|
|
955
1833
|
}
|
|
956
|
-
async start() {
|
|
957
|
-
const updated = await this.
|
|
1834
|
+
async start(call) {
|
|
1835
|
+
const updated = await this.api(call).start(this.id);
|
|
958
1836
|
this.apply(updated.toJSON());
|
|
959
1837
|
return this;
|
|
960
1838
|
}
|
|
961
|
-
async stop() {
|
|
962
|
-
const updated = await this.
|
|
1839
|
+
async stop(call) {
|
|
1840
|
+
const updated = await this.api(call).stop(this.id);
|
|
963
1841
|
this.apply(updated.toJSON());
|
|
964
1842
|
return this;
|
|
965
1843
|
}
|
|
966
|
-
async destroy() {
|
|
967
|
-
await this.
|
|
1844
|
+
async destroy(call) {
|
|
1845
|
+
await this.api(call).destroy(this.id);
|
|
968
1846
|
}
|
|
969
|
-
async resize(options) {
|
|
970
|
-
const updated = await this.
|
|
1847
|
+
async resize(options, call) {
|
|
1848
|
+
const updated = await this.api(call).resize(this.id, options);
|
|
971
1849
|
this.apply(updated.toJSON());
|
|
972
1850
|
return this;
|
|
973
1851
|
}
|
|
974
|
-
async updateLifecycle(lifecycle) {
|
|
975
|
-
const updated = await this.
|
|
1852
|
+
async updateLifecycle(lifecycle, call) {
|
|
1853
|
+
const updated = await this.api(call).updateLifecycle(this.id, lifecycle);
|
|
976
1854
|
this.apply(updated.toJSON());
|
|
977
1855
|
return this;
|
|
978
1856
|
}
|
|
979
|
-
async getNetworkUsage() {
|
|
980
|
-
return this.
|
|
1857
|
+
async getNetworkUsage(call) {
|
|
1858
|
+
return this.api(call).getNetworkUsage(this.id);
|
|
981
1859
|
}
|
|
982
|
-
async setNetworkLimits(options) {
|
|
983
|
-
return this.
|
|
1860
|
+
async setNetworkLimits(options, call) {
|
|
1861
|
+
return this.api(call).setNetworkLimits(this.id, options);
|
|
984
1862
|
}
|
|
985
1863
|
/**
|
|
986
1864
|
* Replaces this sandbox's egress policy while it runs. The call returns
|
|
987
1865
|
* once the new policy is enforced; sending the same policy again is a
|
|
988
1866
|
* no-op, so it is safe to retry.
|
|
989
1867
|
*/
|
|
990
|
-
async setNetworkPolicy(options) {
|
|
991
|
-
const policy = await this.
|
|
1868
|
+
async setNetworkPolicy(options, call) {
|
|
1869
|
+
const policy = await this.api(call).setNetworkPolicy(this.id, options);
|
|
992
1870
|
this.networkBlockAll = policy.networkBlockAll;
|
|
993
1871
|
this.egressStatus = policy.egressStatus;
|
|
994
1872
|
this.egressProfiles = policy.egressProfiles.length ? policy.egressProfiles : undefined;
|
|
@@ -1001,19 +1879,21 @@ export class SandboxResource {
|
|
|
1001
1879
|
* would have allowed it. A recording stays readable after the sandbox
|
|
1002
1880
|
* switches to enforce, until it is destroyed.
|
|
1003
1881
|
*/
|
|
1004
|
-
async learned() {
|
|
1005
|
-
return this.
|
|
1882
|
+
async learned(call) {
|
|
1883
|
+
return this.api(call).getNetworkLearned(this.id);
|
|
1006
1884
|
}
|
|
1007
1885
|
/**
|
|
1008
1886
|
* Reads this sandbox's audit log: outbound connections and egress denials
|
|
1009
1887
|
* (`kind: "egress"`), and secret reads. Denials carry `result: "failure"`
|
|
1010
1888
|
* and the policy `reason`.
|
|
1011
1889
|
*/
|
|
1012
|
-
async audit(options) {
|
|
1013
|
-
return this.
|
|
1890
|
+
async audit(options, call) {
|
|
1891
|
+
return this.api(call).getAudit(this.id, options);
|
|
1014
1892
|
}
|
|
1015
1893
|
toJSON() {
|
|
1016
|
-
|
|
1894
|
+
// Only the sandbox's own fields, not the SDK's flags on this object.
|
|
1895
|
+
const { destroyOnExit: _destroyOnExit, disposed: _disposed, ...data } = this;
|
|
1896
|
+
return cloneSandbox(data);
|
|
1017
1897
|
}
|
|
1018
1898
|
apply(sandbox) {
|
|
1019
1899
|
Object.assign(this, cloneSandbox(sandbox));
|
|
@@ -1045,6 +1925,7 @@ function toApiCreateOptions(options) {
|
|
|
1045
1925
|
platform_volumes: options.platformVolumes?.map(toApiPlatformVolumeMount),
|
|
1046
1926
|
lifecycle: options.lifecycle ? toApiLifecycle(options.lifecycle) : undefined,
|
|
1047
1927
|
failover: options.failover ? toApiFailover(options.failover) : undefined,
|
|
1928
|
+
placement: options.placement ? { zone: options.placement.zone, labels: options.placement.labels } : undefined,
|
|
1048
1929
|
runtime: options.runtime,
|
|
1049
1930
|
durability: options.durability,
|
|
1050
1931
|
module_ref: options.moduleRef,
|
|
@@ -1052,6 +1933,7 @@ function toApiCreateOptions(options) {
|
|
|
1052
1933
|
custom_domains: options.customDomains,
|
|
1053
1934
|
gpus: options.gpus ? toApiGPUOptions(options.gpus) : undefined,
|
|
1054
1935
|
template_id: options.templateId,
|
|
1936
|
+
identity: options.identity ? toApiIdentity(options.identity) : undefined,
|
|
1055
1937
|
};
|
|
1056
1938
|
}
|
|
1057
1939
|
function toApiGPUOptions(gpus) {
|
|
@@ -1085,9 +1967,12 @@ function toApiResizeOptions(options) {
|
|
|
1085
1967
|
function toApiExecRequest(request) {
|
|
1086
1968
|
return {
|
|
1087
1969
|
command: request.command,
|
|
1970
|
+
argv: request.argv,
|
|
1088
1971
|
workdir: request.workDir,
|
|
1089
1972
|
env: request.env,
|
|
1090
1973
|
timeout_seconds: request.timeoutSeconds,
|
|
1974
|
+
max_stdout_bytes: request.maxStdoutBytes,
|
|
1975
|
+
max_stderr_bytes: request.maxStderrBytes,
|
|
1091
1976
|
};
|
|
1092
1977
|
}
|
|
1093
1978
|
function toApiCreateSessionOptions(options) {
|
|
@@ -1109,6 +1994,9 @@ function toApiLifecycle(lifecycle) {
|
|
|
1109
1994
|
stop_at_age: lifecycle.stopAtAge,
|
|
1110
1995
|
destroy_at_age: lifecycle.destroyAtAge,
|
|
1111
1996
|
serverless: lifecycle.serverless,
|
|
1997
|
+
restart_on_failure: lifecycle.restartOnFailure,
|
|
1998
|
+
destroy_after_exit_for: lifecycle.destroyAfterExitFor,
|
|
1999
|
+
destroy_at: lifecycle.destroyAt instanceof Date ? lifecycle.destroyAt.toISOString() : lifecycle.destroyAt,
|
|
1112
2000
|
};
|
|
1113
2001
|
}
|
|
1114
2002
|
function toApiFailover(failover) {
|
|
@@ -1144,6 +2032,9 @@ function fromApiSandbox(sandbox) {
|
|
|
1144
2032
|
updatedAt: sandbox.updated_at,
|
|
1145
2033
|
lastActiveAt: sandbox.last_active_at,
|
|
1146
2034
|
lastError: sandbox.last_error,
|
|
2035
|
+
statusReason: (sandbox.status_reason || undefined),
|
|
2036
|
+
// 0 is a real exit code; only an absent or null one is left out.
|
|
2037
|
+
exitCode: sandbox.exit_code ?? undefined,
|
|
1147
2038
|
containerCommand: sandbox.container_command,
|
|
1148
2039
|
lifecycle: fromApiLifecycle(sandbox.lifecycle),
|
|
1149
2040
|
failover: fromApiFailover(sandbox.failover),
|
|
@@ -1152,6 +2043,7 @@ function fromApiSandbox(sandbox) {
|
|
|
1152
2043
|
moduleRef: sandbox.module_ref,
|
|
1153
2044
|
moduleDigest: sandbox.module_digest,
|
|
1154
2045
|
tenantId: sandbox.tenant_id,
|
|
2046
|
+
identity: fromApiSandboxIdentity(sandbox.identity),
|
|
1155
2047
|
};
|
|
1156
2048
|
}
|
|
1157
2049
|
// normalizeRuntime narrows the wire-level string to the union we expose, while
|
|
@@ -1257,6 +2149,7 @@ function fromApiExposedPort(port) {
|
|
|
1257
2149
|
port: port.port,
|
|
1258
2150
|
publicURL: port.public_url,
|
|
1259
2151
|
createdAt: port.created_at,
|
|
2152
|
+
...(port.visibility === "token" ? { visibility: "token" } : {}),
|
|
1260
2153
|
};
|
|
1261
2154
|
}
|
|
1262
2155
|
function fromApiExposePortResponse(response) {
|
|
@@ -1271,7 +2164,9 @@ function fromApiExposePortResponse(response) {
|
|
|
1271
2164
|
case "tls":
|
|
1272
2165
|
return { protocol: "tls", url: response.public_url };
|
|
1273
2166
|
case "http":
|
|
1274
|
-
return
|
|
2167
|
+
return response.visibility === "token"
|
|
2168
|
+
? { protocol: "http", url: response.public_url, visibility: "token" }
|
|
2169
|
+
: { protocol: "http", url: response.public_url };
|
|
1275
2170
|
}
|
|
1276
2171
|
}
|
|
1277
2172
|
function fromApiExecResult(result) {
|
|
@@ -1280,6 +2175,10 @@ function fromApiExecResult(result) {
|
|
|
1280
2175
|
stderr: result.stderr,
|
|
1281
2176
|
exitCode: result.exit_code,
|
|
1282
2177
|
durationMS: result.duration_ms,
|
|
2178
|
+
timedOut: result.timed_out ?? false,
|
|
2179
|
+
stdoutTruncated: result.stdout_truncated ?? false,
|
|
2180
|
+
stderrTruncated: result.stderr_truncated ?? false,
|
|
2181
|
+
signal: result.signal || undefined,
|
|
1283
2182
|
};
|
|
1284
2183
|
}
|
|
1285
2184
|
function fromApiHealthStatus(status) {
|
|
@@ -1313,6 +2212,7 @@ function toApiPlatformVolumeMount(volume) {
|
|
|
1313
2212
|
name: volume.name,
|
|
1314
2213
|
path: volume.path,
|
|
1315
2214
|
read_only: volume.readOnly,
|
|
2215
|
+
delete_with_sandbox: volume.deleteWithSandbox,
|
|
1316
2216
|
};
|
|
1317
2217
|
}
|
|
1318
2218
|
function fromApiMountSpecRedacted(mount) {
|
|
@@ -1385,6 +2285,12 @@ function fromApiLifecycle(lifecycle) {
|
|
|
1385
2285
|
if (lifecycle?.serverless) {
|
|
1386
2286
|
result.serverless = true;
|
|
1387
2287
|
}
|
|
2288
|
+
if (lifecycle?.restart_on_failure) {
|
|
2289
|
+
result.restartOnFailure = true;
|
|
2290
|
+
}
|
|
2291
|
+
if (lifecycle?.destroy_after_exit_for !== undefined) {
|
|
2292
|
+
result.destroyAfterExitFor = lifecycle.destroy_after_exit_for;
|
|
2293
|
+
}
|
|
1388
2294
|
return result;
|
|
1389
2295
|
}
|
|
1390
2296
|
function fromApiFailover(failover) {
|
|
@@ -1439,6 +2345,7 @@ function cloneSandbox(sandbox) {
|
|
|
1439
2345
|
containerCommand: sandbox.containerCommand ? [...sandbox.containerCommand] : undefined,
|
|
1440
2346
|
lifecycle: { ...sandbox.lifecycle },
|
|
1441
2347
|
failover: sandbox.failover ? { ...sandbox.failover } : undefined,
|
|
2348
|
+
identity: sandbox.identity ? { ...sandbox.identity } : undefined,
|
|
1442
2349
|
};
|
|
1443
2350
|
}
|
|
1444
2351
|
function toBlob(data) {
|
|
@@ -1453,23 +2360,91 @@ function toBlob(data) {
|
|
|
1453
2360
|
}
|
|
1454
2361
|
return new Blob([Uint8Array.from(data)]);
|
|
1455
2362
|
}
|
|
2363
|
+
/** The folder's contents as tar entries, names relative to it, sorted. */
|
|
2364
|
+
async function folderEntries(root, prefix) {
|
|
2365
|
+
const entries = [];
|
|
2366
|
+
const names = (await readdir(join(root, prefix))).sort();
|
|
2367
|
+
for (const name of names) {
|
|
2368
|
+
const rel = prefix === "" ? name : `${prefix}/${name}`;
|
|
2369
|
+
const full = join(root, rel);
|
|
2370
|
+
const info = await lstat(full);
|
|
2371
|
+
const base = { name: rel, mode: info.mode & 0o7777, mtime: Math.floor(info.mtimeMs / 1000) };
|
|
2372
|
+
if (info.isSymbolicLink()) {
|
|
2373
|
+
entries.push({ ...base, type: "symlink", linkname: await readlink(full) });
|
|
2374
|
+
}
|
|
2375
|
+
else if (info.isDirectory()) {
|
|
2376
|
+
entries.push({ ...base, type: "dir" }, ...(await folderEntries(root, rel)));
|
|
2377
|
+
}
|
|
2378
|
+
else if (info.isFile()) {
|
|
2379
|
+
entries.push({ ...base, type: "file", data: await readFile(full) });
|
|
2380
|
+
}
|
|
2381
|
+
// Devices, pipes and sockets are skipped: the sandbox refuses them.
|
|
2382
|
+
}
|
|
2383
|
+
return entries;
|
|
2384
|
+
}
|
|
2385
|
+
/** The `code` of a JSON error reply, read from a clone so the body stays readable. */
|
|
2386
|
+
async function errorCode(response) {
|
|
2387
|
+
try {
|
|
2388
|
+
const payload = (await response.clone().json());
|
|
2389
|
+
return typeof payload.code === "string" ? payload.code : undefined;
|
|
2390
|
+
}
|
|
2391
|
+
catch {
|
|
2392
|
+
return undefined;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
/**
|
|
2396
|
+
* A missing file, not a missing sandbox (also a 404). Current agents say
|
|
2397
|
+
* file_not_found; older ones send no code, but their message is the OS
|
|
2398
|
+
* error, which ends "no such file or directory".
|
|
2399
|
+
*/
|
|
2400
|
+
function isMissingFile(err) {
|
|
2401
|
+
if (!(err instanceof MicroVMApiError) || err.status !== 404) {
|
|
2402
|
+
return false;
|
|
2403
|
+
}
|
|
2404
|
+
return err.code === "file_not_found" || (err.code === undefined && err.message.endsWith("no such file or directory"));
|
|
2405
|
+
}
|
|
2406
|
+
/**
|
|
2407
|
+
* An older agent ignores extended=true and sends only Daytona's fields (a
|
|
2408
|
+
* 32-bit size, no path), so each extended field falls back.
|
|
2409
|
+
*/
|
|
2410
|
+
function fromApiFileInfo(item, fallbackPath) {
|
|
2411
|
+
const info = {
|
|
2412
|
+
name: item.name ?? "",
|
|
2413
|
+
path: item.path || fallbackPath,
|
|
2414
|
+
isDir: item.isDir ?? false,
|
|
2415
|
+
size: item.size_bytes ?? item.size ?? 0,
|
|
2416
|
+
mode: item.mode ?? "",
|
|
2417
|
+
permissions: item.permissions ?? "",
|
|
2418
|
+
owner: item.owner ?? "",
|
|
2419
|
+
group: item.group ?? "",
|
|
2420
|
+
modTime: item.modTime ?? "",
|
|
2421
|
+
};
|
|
2422
|
+
if (typeof item.symlink_target === "string") {
|
|
2423
|
+
info.symlinkTarget = item.symlink_target;
|
|
2424
|
+
}
|
|
2425
|
+
return info;
|
|
2426
|
+
}
|
|
1456
2427
|
async function decodeError(response) {
|
|
1457
2428
|
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"), Date.now());
|
|
1458
2429
|
try {
|
|
1459
2430
|
const payload = (await response.json());
|
|
1460
2431
|
if (typeof payload.error === "string" && payload.error !== "") {
|
|
1461
2432
|
const code = typeof payload.code === "string" && payload.code !== "" ? payload.code : undefined;
|
|
1462
|
-
return
|
|
2433
|
+
return apiErrorFor(response.status, payload.error, { code, retryAfterMs });
|
|
1463
2434
|
}
|
|
1464
2435
|
}
|
|
1465
2436
|
catch {
|
|
1466
2437
|
// Not JSON (a proxy's error page) or no body: fall through to the
|
|
1467
2438
|
// status-based message, with no code.
|
|
1468
2439
|
}
|
|
1469
|
-
return
|
|
2440
|
+
return apiErrorFor(response.status, `request failed with status ${response.status}`, { retryAfterMs });
|
|
1470
2441
|
}
|
|
1471
2442
|
const STREAM_PREFIX_STDOUT = 0x01;
|
|
1472
2443
|
const STREAM_PREFIX_STDERR = 0x02;
|
|
2444
|
+
// toolboxd marks a 404 that means "no such route" with this header, as
|
|
2445
|
+
// opposed to a route that answered 404 for a missing file.
|
|
2446
|
+
const TOOLBOX_ROUTE_HEADER = "x-toolbox-route";
|
|
2447
|
+
const FILE_WATCH_FEATURE = "files.watch";
|
|
1473
2448
|
// Pull whatever diagnostic detail the runtime gave us off a WebSocket "error"
|
|
1474
2449
|
// event. Node 22's native WebSocket fires an ErrorEvent with `.error` /
|
|
1475
2450
|
// `.message`; browsers' base WebSocket fires a bare Event. Returns "" when
|
|
@@ -1505,7 +2480,7 @@ function toWebSocketBinaryFrame(data) {
|
|
|
1505
2480
|
frame.set(bytes);
|
|
1506
2481
|
return frame;
|
|
1507
2482
|
}
|
|
1508
|
-
function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options) {
|
|
2483
|
+
function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options, ready) {
|
|
1509
2484
|
const wsURL = baseURL.replace(/^http/, "ws") + `${versionPrefix}/sandboxes/${encodeURIComponent(sandboxID)}/toolbox/process/exec/stream`;
|
|
1510
2485
|
const WS = globalThis.WebSocket;
|
|
1511
2486
|
if (!WS) {
|
|
@@ -1537,17 +2512,51 @@ function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options) {
|
|
|
1537
2512
|
if (resolved)
|
|
1538
2513
|
return;
|
|
1539
2514
|
resolved = true;
|
|
1540
|
-
exitReject?.(new Error(msg));
|
|
2515
|
+
exitReject?.(typeof msg === "string" ? new Error(msg) : msg);
|
|
1541
2516
|
};
|
|
1542
|
-
|
|
2517
|
+
// Frames the caller sends after the socket opened but before a delayed
|
|
2518
|
+
// start message (argv's feature check) wait here, so the start message is
|
|
2519
|
+
// always first. Before the socket opens, send fails as it always has.
|
|
2520
|
+
let started = false;
|
|
2521
|
+
const pending = [];
|
|
2522
|
+
const send = (data) => {
|
|
2523
|
+
if (!started && ws.readyState === 1) {
|
|
2524
|
+
pending.push(data);
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
ws.send(data);
|
|
2528
|
+
};
|
|
2529
|
+
const sendStart = () => {
|
|
1543
2530
|
ws.send(JSON.stringify({
|
|
1544
2531
|
command: options.command,
|
|
2532
|
+
argv: options.argv,
|
|
1545
2533
|
workdir: options.workdir,
|
|
1546
2534
|
env: options.env,
|
|
1547
2535
|
tty: options.tty ?? false,
|
|
1548
2536
|
cols: options.cols ?? 0,
|
|
1549
2537
|
rows: options.rows ?? 0,
|
|
1550
2538
|
}));
|
|
2539
|
+
started = true;
|
|
2540
|
+
for (const data of pending.splice(0)) {
|
|
2541
|
+
ws.send(data);
|
|
2542
|
+
}
|
|
2543
|
+
};
|
|
2544
|
+
// The check can fail before the socket opens; mark it handled now so
|
|
2545
|
+
// Node does not report an unhandled rejection, and act on it at "open".
|
|
2546
|
+
ready?.catch(() => undefined);
|
|
2547
|
+
ws.addEventListener("open", () => {
|
|
2548
|
+
if (!ready) {
|
|
2549
|
+
sendStart();
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
ready.then(() => {
|
|
2553
|
+
if (!resolved) {
|
|
2554
|
+
sendStart();
|
|
2555
|
+
}
|
|
2556
|
+
}, (err) => {
|
|
2557
|
+
failWith(err instanceof Error ? err : new Error(String(err)));
|
|
2558
|
+
ws.close();
|
|
2559
|
+
});
|
|
1551
2560
|
});
|
|
1552
2561
|
ws.addEventListener("message", (event) => {
|
|
1553
2562
|
if (typeof event.data === "string") {
|
|
@@ -1595,18 +2604,18 @@ function openExecStream(baseURL, versionPrefix, patToken, sandboxID, options) {
|
|
|
1595
2604
|
failWith(detail !== "" ? `websocket error: ${detail}` : "websocket error");
|
|
1596
2605
|
});
|
|
1597
2606
|
const sendBinary = (data) => {
|
|
1598
|
-
|
|
2607
|
+
send(toWebSocketBinaryFrame(data));
|
|
1599
2608
|
};
|
|
1600
2609
|
return {
|
|
1601
2610
|
write: sendBinary,
|
|
1602
2611
|
resize(cols, rows) {
|
|
1603
|
-
|
|
2612
|
+
send(JSON.stringify({ type: "resize", cols, rows }));
|
|
1604
2613
|
},
|
|
1605
2614
|
signal(name) {
|
|
1606
|
-
|
|
2615
|
+
send(JSON.stringify({ type: "signal", signal: name }));
|
|
1607
2616
|
},
|
|
1608
2617
|
close() {
|
|
1609
|
-
|
|
2618
|
+
send(JSON.stringify({ type: "close" }));
|
|
1610
2619
|
},
|
|
1611
2620
|
done,
|
|
1612
2621
|
};
|
|
@@ -1711,3 +2720,152 @@ function openSessionAttach(baseURL, versionPrefix, patToken, sandboxID, sessionI
|
|
|
1711
2720
|
done,
|
|
1712
2721
|
};
|
|
1713
2722
|
}
|
|
2723
|
+
function fromApiFileEvent(raw) {
|
|
2724
|
+
const e = (raw ?? {});
|
|
2725
|
+
const event = {
|
|
2726
|
+
type: typeof e.type === "string" ? e.type : "",
|
|
2727
|
+
path: typeof e.path === "string" ? e.path : "",
|
|
2728
|
+
isDir: e.is_dir === true,
|
|
2729
|
+
};
|
|
2730
|
+
if (typeof e.reason === "string" && e.reason !== "") {
|
|
2731
|
+
event.reason = e.reason;
|
|
2732
|
+
}
|
|
2733
|
+
return event;
|
|
2734
|
+
}
|
|
2735
|
+
// openFileWatch opens toolboxd's /files/watch stream and resolves with its
|
|
2736
|
+
// handle once the "start" frame says the watch is in place. `refusal`
|
|
2737
|
+
// explains a handshake that never opened.
|
|
2738
|
+
// How long a file watch waits for the agent to say the watch is armed.
|
|
2739
|
+
export const WATCH_START_TIMEOUT_MS = 60_000;
|
|
2740
|
+
function openFileWatch(baseURL, routePath, patToken, onEvent, refusal) {
|
|
2741
|
+
const WS = globalThis.WebSocket;
|
|
2742
|
+
if (!WS) {
|
|
2743
|
+
return Promise.reject(new Error("WebSocket is not available in this runtime — Node 22+ or a browser is required"));
|
|
2744
|
+
}
|
|
2745
|
+
// The PAT rides the subprotocol, as on execStream: browsers cannot set
|
|
2746
|
+
// Authorization on a WebSocket handshake.
|
|
2747
|
+
const ws = new WS(baseURL.replace(/^http/, "ws") + routePath, ["sandbox.bearer", patToken]);
|
|
2748
|
+
let startResolve;
|
|
2749
|
+
let startReject;
|
|
2750
|
+
const started = new Promise((resolve, reject) => {
|
|
2751
|
+
startResolve = resolve;
|
|
2752
|
+
startReject = reject;
|
|
2753
|
+
});
|
|
2754
|
+
let endResolve;
|
|
2755
|
+
let endReject;
|
|
2756
|
+
const ended = new Promise((resolve, reject) => {
|
|
2757
|
+
endResolve = resolve;
|
|
2758
|
+
endReject = reject;
|
|
2759
|
+
});
|
|
2760
|
+
// A caller that never calls wait() must not see an unhandled rejection.
|
|
2761
|
+
ended.catch(() => undefined);
|
|
2762
|
+
let opened = false;
|
|
2763
|
+
let isStarted = false;
|
|
2764
|
+
let startFailed = false;
|
|
2765
|
+
let settled = false;
|
|
2766
|
+
let lastErrorDetail = "";
|
|
2767
|
+
const finish = (err) => {
|
|
2768
|
+
if (settled)
|
|
2769
|
+
return;
|
|
2770
|
+
settled = true;
|
|
2771
|
+
if (err) {
|
|
2772
|
+
endReject(err);
|
|
2773
|
+
}
|
|
2774
|
+
else {
|
|
2775
|
+
endResolve();
|
|
2776
|
+
}
|
|
2777
|
+
};
|
|
2778
|
+
const handle = {
|
|
2779
|
+
stop() {
|
|
2780
|
+
finish();
|
|
2781
|
+
ws.close();
|
|
2782
|
+
},
|
|
2783
|
+
wait() {
|
|
2784
|
+
return ended;
|
|
2785
|
+
},
|
|
2786
|
+
};
|
|
2787
|
+
const fail = (detail) => {
|
|
2788
|
+
clearTimeout(startTimer);
|
|
2789
|
+
if (isStarted) {
|
|
2790
|
+
finish(new Error(`file watch stream closed${detail}`));
|
|
2791
|
+
return;
|
|
2792
|
+
}
|
|
2793
|
+
if (startFailed)
|
|
2794
|
+
return;
|
|
2795
|
+
startFailed = true;
|
|
2796
|
+
if (opened) {
|
|
2797
|
+
startReject(new Error(`file watch closed before it started${detail}`));
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
void refusal(detail).then(startReject, startReject);
|
|
2801
|
+
};
|
|
2802
|
+
// The wait for "start" is bounded: a proxy that accepts the upgrade and
|
|
2803
|
+
// then goes quiet would otherwise leave the caller waiting forever. Later
|
|
2804
|
+
// frames are not bounded, since a quiet folder sends nothing.
|
|
2805
|
+
const startTimer = setTimeout(() => {
|
|
2806
|
+
if (isStarted || startFailed)
|
|
2807
|
+
return;
|
|
2808
|
+
startFailed = true;
|
|
2809
|
+
startReject(new Error(`file watch did not start within ${WATCH_START_TIMEOUT_MS / 1000}s`));
|
|
2810
|
+
ws.close();
|
|
2811
|
+
}, WATCH_START_TIMEOUT_MS);
|
|
2812
|
+
// In Node, the pending wait alone must not keep the process alive.
|
|
2813
|
+
startTimer.unref?.();
|
|
2814
|
+
ws.addEventListener("open", () => {
|
|
2815
|
+
opened = true;
|
|
2816
|
+
});
|
|
2817
|
+
ws.addEventListener("message", (event) => {
|
|
2818
|
+
if (typeof event.data !== "string")
|
|
2819
|
+
return;
|
|
2820
|
+
let msg;
|
|
2821
|
+
try {
|
|
2822
|
+
msg = JSON.parse(event.data);
|
|
2823
|
+
}
|
|
2824
|
+
catch {
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
switch (msg.type) {
|
|
2828
|
+
case "start":
|
|
2829
|
+
clearTimeout(startTimer);
|
|
2830
|
+
if (!isStarted && !startFailed) {
|
|
2831
|
+
isStarted = true;
|
|
2832
|
+
startResolve(handle);
|
|
2833
|
+
}
|
|
2834
|
+
return;
|
|
2835
|
+
case "events":
|
|
2836
|
+
if (!Array.isArray(msg.events))
|
|
2837
|
+
return;
|
|
2838
|
+
for (const raw of msg.events) {
|
|
2839
|
+
// stop() inside the callback ends delivery at once.
|
|
2840
|
+
if (settled)
|
|
2841
|
+
return;
|
|
2842
|
+
try {
|
|
2843
|
+
onEvent(fromApiFileEvent(raw));
|
|
2844
|
+
}
|
|
2845
|
+
catch (err) {
|
|
2846
|
+
// A throwing callback ends the watch; wait() reports why.
|
|
2847
|
+
finish(err instanceof Error ? err : new Error(String(err)));
|
|
2848
|
+
ws.close();
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
return;
|
|
2853
|
+
case "end":
|
|
2854
|
+
finish(new Error(typeof msg.reason === "string" && msg.reason !== "" ? msg.reason : "file watch ended"));
|
|
2855
|
+
ws.close();
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
});
|
|
2859
|
+
ws.addEventListener("close", (event) => {
|
|
2860
|
+
const parts = [lastErrorDetail, describeWSClose(event)].filter((s) => s !== "");
|
|
2861
|
+
fail(parts.length > 0 ? ` (${parts.join("; ")})` : "");
|
|
2862
|
+
});
|
|
2863
|
+
ws.addEventListener("error", (event) => {
|
|
2864
|
+
const detail = describeWSError(event);
|
|
2865
|
+
if (detail !== "") {
|
|
2866
|
+
lastErrorDetail = detail;
|
|
2867
|
+
}
|
|
2868
|
+
fail(detail !== "" ? ` (${detail})` : "");
|
|
2869
|
+
});
|
|
2870
|
+
return started;
|
|
2871
|
+
}
|